Chapter 8
JAPE: Regular Expressions over Annotations [#]
If Osama bin Laden did not exist, it would be necessary to invent him. For the past four years, his name has been invoked whenever a US president has sought to increase the defence budget or wriggle out of arms control treaties. He has been used to justify even President Bush’s missile defence programme, though neither he nor his associates are known to possess anything approaching ballistic missile technology. Now he has become the personification of evil required to launch a crusade for good: the face behind the faceless terror.
The closer you look, the weaker the case against Bin Laden becomes. While the terrorists who inflicted Tuesday’s dreadful wound may have been inspired by him, there is, as yet, no evidence that they were instructed by him. Bin Laden’s presumed guilt appears to rest on the supposition that he is the sort of man who would have done it. But his culpability is irrelevant: his usefulness to western governments lies in his power to terrify. When billions of pounds of military spending are at stake, rogue states and terrorist warlords become assets precisely because they are liabilities.
The need for dissent, George Monbiot, The Guardian, Tuesday September 18, 2001.
JAPE is a Java Annotation Patterns Engine. JAPE provides finite state transduction over annotations based on regular expressions. JAPE is a version of CPSL – Common Pattern Specification Language1. This chapter introduces JAPE, and outlines the functionality available. (You can find an excellent tutorial here; thanks to Dhaval Thakker, Taha Osmin and Phil Lakin).
JAPE allows you to recognise regular expressions in annotations on documents. Hang on, there’s something wrong here: a regular language can only describe sets of strings, not graphs, and GATE’s model of annotations is based on graphs. Hmmm. Another way of saying this: typically, regular expressions are applied to character strings, a simple linear sequence of items, but here we are applying them to a much more complex data structure. The result is that in certain cases the matching process is non-deterministic (i.e. the results are dependent on random factors like the addresses at which data is stored in the virtual machine): when there is structure in the graph being matched that requires more than the power of a regular automaton to recognise, JAPE chooses an alternative arbitrarily. However, this is not the bad news that it seems to be, as it turns out that in many useful cases the data stored in annotation graphs in GATE (and other language processing systems) can be regarded as simple sequences, and matched deterministically with regular expressions.
A JAPE grammar consists of a set of phases, each of which consists of a set of pattern/action rules. The phases run sequentially and constitute a cascade of finite state transducers over annotations. The left-hand-side (LHS) of the rules consist of an annotation pattern description. The right-hand-side (RHS) consists of annotation manipulation statements. Annotations matched on the LHS of a rule may be referred to on the RHS by means of labels that are attached to pattern elements. Consider the following example:
Phase: Jobtitle Input: Lookup Options: control = appelt debug = true Rule: Jobtitle1 ( {Lookup.majorType == jobtitle} ( {Lookup.majorType == jobtitle} )? ) :jobtitle --> :jobtitle.JobTitle = {rule = "JobTitle1"} |
The LHS is the part preceding the ‘-->’ and the RHS is the part following it. The LHS speficies a pattern to be matched to the annotated GATE document, whereas the RHS specifies what is to be done to the matched text. In this example, we have a rule entitled ‘Jobtitle1’, which will match text annotated with a ‘Lookup’ annotation with a ‘majorType’ feature of ‘jobtitle’, followed optionally by further text annotated as a ‘Lookup’ with ‘majorType’ of ‘jobtitle’. Once this rule has matched a sequence of text, the entire sequence is allocated a label by the rule, and in this case, the label is ‘jobtitle’. On the RHS, we refer to this span of text using the label given in the LHS; ‘jobtitle’. We say that this text is to be given an annotation of type ‘JobTitle’ and a ‘rule’ feature set to ‘JobTitle1’.
We began the JAPE grammar by giving it a phase name; ‘Phase: Jobtitle’. JAPE grammars can be cascaded, and so each grammar is considered to be a ‘phase’ (see Section 8.5). We also provide a list of the annotation types we will use in the grammar. In this case, we say ‘Input: Lookup’ because the only annotation type we use on the LHS are Lookup annotations. If no annotations are defined, all annotations will be matched.
Then, several options are set:
- Control; in this case, ‘appelt’. This defines the method of rule matching (see Section 8.4)
- Debug. When set to true, if the grammar is running in Appelt mode and there is more than one possible match, the conflicts will be displayed on the standard output.
A wide range of functionality can be used with JAPE, making it a very powerful system. Section 8.1 gives an overview of some common LHS tasks. Section 8.2 talks about the various operators available for use on the LHS. After that, Section 8.3 outlines RHS functionality. Section 8.4 talks about priority and Section 8.5 talks about phases. Section 8.6 talks about using Java code on the RHS, which is the main way of increasing the power of the RHS. We conclude the chapter with some miscellaneous JAPE-related topics of interest.
8.1 The Left-Hand Side [#]
The LHS of a JAPE grammar aims to match the text span to be annotated, whilst avoiding undesirable matches. There are various tools available to enable you to do this. This section outlines how you would approach various common tasks on the LHS of your JAPE grammar.
8.1.1 Matching a Simple Text String
To match a simple text string, you need to refer to a feature on an annotation that contains the string; for example,
{Token.string == "of"}
|
The following grammar shows a sequence of strings being matched. Bracketing, along with the ‘or’ operator, is used to define how the strings should come together:
Phase: UrlPre
Input: Token SpaceToken Options: control = appelt Rule: Urlpre ( (({Token.string == "http"} | {Token.string == "ftp"}) {Token.string == ":"} {Token.string == "/"} {Token.string == "/"} ) | ({Token.string == "www"} {Token.string == "."} ) ):urlpre --> :urlpre.UrlPre = {rule = "UrlPre"} |
8.1.2 Matching Entire Annotation Types
You can specify the presence of an annotation previously assigned from a gazetteer, tokeniser, or other module. For example, the following will match a Lookup annotation:
{Lookup}
|
The following will match if there is not a Lookup annotation at this location:
{!Lookup}
|
The following rule shows several different annotation types being matched. We also see a string being matched, and again, the use of the ‘or’ operator:
Rule: Known
Priority: 100 ( {Location}| {Person}| {Date}| {Organization}| {Address}| {Money} | {Percent}| {Token.string == "Dear"}| {JobTitle}| {Lookup} ):known --> {} |
8.1.3 Using Attributes and Values
You can specify the attributes (and values) of an annotation to be matched. Several operators are supported; see Section 8.2 for full details:
- {Token.kind == "number"}, {Token.length != 4} - equality and inequality.
- {Token.string > "aardvark"}, {Token.length < 10} - comparison operators. >= and <= are also supported.
- {Token.string =~ "[Dd]ogs"}, {Token.string !~ "(?i)hello"} - regular expression. ==~ and !=~ are also provided, for whole-string matching.
- {X contains Y} and {X within Y} for checking annotations within the context of other annotations.
In the following rule, the ‘category’ feature of the ‘Token’ annotation is used, along with the ‘equals’ operator:
Rule: Unknown
Priority: 50 ( {Token.category == NNP} ) :unknown --> :unknown.Unknown = {kind = "PN", rule = Unknown} |
8.1.4 Using Meta-Properties [#]
In addition to referencing annotation features, JAPE allows access to other ‘meta-properties’ of an annotation. This is done by using an ‘@’ symbol rather than a ‘.’ symbol after the annotation type name. The three meta-properties that are built in are:
- length - returns the spanning length of the annotation.
- string - returns the string spanned by the annotation in the document.
- cleanString - Like string, but with extra white space stripped out. (i.e. ‘\s+’ goes to a single space and leading or trailing white space is removed).
{X@length > "5"}:label-->:label.New = {}
|
At this time, you cannot access the value of a ‘meta-property’ from a non-java RHS of a rule, e.g. you can’t write:
{X@length > "5"}:label-->:label.New = {somefeat = :label.X@length }
|
We hope to add this at some point.
8.1.5 Multiple Pattern/Action Pairs
It is also possible to have more than one pattern and corresponding action, as shown in the rule below. On the LHS, each pattern is enclosed in a set of round brackets and has a unique label; on the RHS, each label is associated with an action. In this example, the Lookup annotation is labelled ‘jobtitle’ and is given the new annotation JobTitle; the TempPerson annotation is labelled ‘person’ and is given the new annotation ‘Person’.
Rule: PersonJobTitle
Priority: 20 ( {Lookup.majorType == jobtitle} ):jobtitle ( {TempPerson} ):person --> :jobtitle.JobTitle = {rule = "PersonJobTitle"}, :person.Person = {kind = "personName", rule = "PersonJobTitle"} |
Similarly, labelled patterns can be nested, as in the example below, where the whole pattern is annotated as Person, but within the pattern, the jobtitle is annotated as JobTitle.
Rule: PersonJobTitle2
Priority: 20 ( ( {Lookup.majorType == jobtitle} ):jobtitle {TempPerson} ):person --> :jobtitle.JobTitle = {rule = "PersonJobTitle"}, :person.Person = {kind = "personName", rule = "PersonJobTitle"} |
8.1.6 LHS Macros [#]
Macros allow you to create a definition that can then be used multiple times in your JAPE rules. In the following JAPE grammar, we have a cascade of macros used. The macro ‘AMOUNT_NUMBER’ makes use of the macros ‘MILLION_BILLION’ and ‘NUMBER_WORDS’, and the rule ‘MoneyCurrencyUnit’ then makes use of ‘AMOUNT_NUMBER’:
Phase: Number
Input: Token Lookup Options: control = appelt Macro: MILLION_BILLION ({Token.string == "m"}| {Token.string == "million"}| {Token.string == "b"}| {Token.string == "billion"}| {Token.string == "bn"}| {Token.string == "k"}| {Token.string == "K"} ) Macro: NUMBER_WORDS ( (({Lookup.majorType == number} ({Token.string == "-"})? )* {Lookup.majorType == number} {Token.string == "and"} )* ({Lookup.majorType == number} ({Token.string == "-"})? )* {Lookup.majorType == number} ) Macro: AMOUNT_NUMBER (({Token.kind == number} (({Token.string == ","}| {Token.string == "."} ) {Token.kind == number} )* | (NUMBER_WORDS) ) (MILLION_BILLION)? ) Rule: MoneyCurrencyUnit ( (AMOUNT_NUMBER) ({Lookup.majorType == currency_unit}) ) :number --> :number.Money = {kind = "number", rule = "MoneyCurrencyUnit"} |
8.1.7 Using Context [#]
Context can be dealt with in the grammar rules in the following way. The pattern to be annotated is always enclosed by a set of round brackets. If preceding context is to be included in the rule, this is placed before this set of brackets. This context is described in exactly the same way as the pattern to be matched. If context following the pattern needs to be included, it is placed after the label given to the annotation. Context is used where a pattern should only be recognised if it occurs in a certain situation, but the context itself does not form part of the pattern to be annotated.
For example, the following rule for Time (assuming an appropriate macro for ‘year’) would mean that a year would only be recognised if it occurs preceded by the words ‘in’ or ‘by’:
Rule: YearContext1
({Token.string == "in"}| {Token.string == "by"} ) (YEAR) :date --> :date.Timex = {kind = "date", rule = "YearContext1"} |
Similarly, the following rule (assuming an appropriate macro for ‘email’) would mean that an email address would only be recognised if it occurred inside angled brackets (which would not themselves form part of the entity):
Rule: Emailaddress1
({Token.string == ‘<’}) ( (EMAIL) ) ({Token.string == ‘>’}) --> :email.Address= {kind = "email", rule = "Emailaddress1"} |
Also, it is possible to specify the constraint that one annotation must start at the same place as another. For example:
Rule: SurnameStartingWithDe
( {Token.string == "de", Lookup.majorType == "name", Lookup.minorType == "surname"} ):de --> :de.Surname = {prefix = "de"} |
This rule would match anywhere where a Token with string ‘de’ and a Lookup with majorType ‘name’ and minorType ‘surname’ start at the same offset in the text. Both the Lookup and Token annotations would be included in the :de binding, so the Surname annotation generated would span the longer of the two. Constraints on the same annotation type must be satisfied by a single annotation, so in this example there must be a single Lookup matching both the major and minor types – the rule would not match if there were two different lookups at the same location, one of them satisfying each constraint.
It is important to remember that context is consumed by the rule, so it cannot be reused in another rule within the same phase. So, for example, right context cannot be used as left context for another rule.
8.1.8 Multi-Constraint Statements [#]
In the examples we have seen so far, most statements have contained only one constraint. For example, in this statement, the ‘category’ of ‘Token’ must equal ‘NNP’:
Rule: Unknown
Priority: 50 ( {Token.category == NNP} ) :unknown --> :unknown.Unknown = {kind = "PN", rule = Unknown} |
However, it is equally acceptable to have multiple constraints in a statement. In this example, the ‘majorType’ of ‘Lookup’ must be ‘name’ and the ‘minorType’ must be ‘surname’:
Rule: Surname
( {Lookup.majorType == "name", Lookup.minorType == "surname"} ):surname --> :surname.Surname = {} |
As we saw in Section 8.1.7, the constraints may refer to different annotations. In this example, in addition to the constraints on the ‘majorType’ and ‘minorType’ of ‘Lookup’, we also have a constraint on the ‘string’ of ‘Token’:
Rule: SurnameStartingWithDe
( {Token.string == "de", Lookup.majorType == "name", Lookup.minorType == "surname"} ):de --> :de.Surname = {prefix = "de"} |
8.1.9 Negation [#]
All the examples in the preceding sections involve constraints that require the presence of certain annotations to match. JAPE also supports ‘negative’ constraints which specify the absence of annotations. A negative constraint is signalled in the grammar by a ‘!’ character.
Negative constraints are generally used in combination with positive ones to constrain the locations at which the positive constraint can match. For example:
Rule: PossibleName
( {Token.orth == "upperInitial", !Lookup} ):name --> :name.PossibleName = {} |
This rule would match any uppercase-initial Token, but only where there is no Lookup annotation starting at the same location. The general rule is that a negative constraint matches at any location where the corresponding positive constraint would not match. Negative constraints do not contribute any annotations to the bindings - in the example above, the :name binding would contain only the Token annotation. The exception to this is when a negative constraint is used alone, without any positive constraints in the combination. In this case it binds all the annotations at the match position that do not match the constraint. Thus, {!Lookup} would bind all the annotations starting at this location except Lookups. In most cases, negative constraints should only be used in combination with positive ones.
Any constraint can be negated, for example:
Rule: SurnameNotStartingWithDe
( {Surname, !Token.string ==~ "[Dd]e"} ):name --> :name.NotDe = {} |
This would match any Surname annotation that does not start at the same place as a Token with the string ‘de’ or ‘De’. Note that this is subtly different from {Surname, Token.string !=~ "[Dd]e"}, as the second form requires a Token annotation to be present, whereas the first form (!Token...) will match if there is no Token annotation at all at this location.2
Although JAPE provides an operator to look for the absence of a single annotation type, there is no support for a general negative operator to prevent a rule from firing if a particular sequence of annotations is found. One solution to this is to create a ‘negative rule’ which has higher priority than the matching ‘positive rule’. The style of matching must be Appelt for this to work. To create a negative rule, simply state on the LHS of the rule the pattern that should NOT be matched, and on the RHS do nothing. In this way, the positive rule cannot be fired if the negative pattern matches, and vice versa, which has the same end result as using a negative operator. A useful variation for developers is to create a dummy annotation on the RHS of the negative rule, rather than to do nothing, and to give the dummy annotation a rule feature. In this way, it is obvious that the negative rule has fired. Alternatively, use Java code on the RHS to print a message when the rule fires. An example of a matching negative and positive rule follows. Here, we want a rule which matches a surname followed by a comma and a set of initials. But we want to specify that the initials shouldn’t have the POS category PRP (personal pronoun). So we specify a negative rule that will fire if the PRP category exists, thereby preventing the positive rule from firing.
Rule: NotPersonReverse
Priority: 20 // we don’t want to match ’Jones, I’ ( {Token.category == NNP} {Token.string == ","} {Token.category == PRP} ) :foo --> {} Rule: PersonReverse Priority: 5 // we want to match ‘Jones, F.W.’ ( {Token.category == NNP} {Token.string == ","} (INITIALS)? ) :person --> |
8.1.10 Escaping Special Characters
To specify a single or double quote as a string, precede it with a backslash, e.g.
{Token.string=="\""}
|
will match a double quote. For other special characters, such as ‘$’, enclose it in double quotes, e.g.
{Token.category == "PRP\$"}
|
8.2 LHS Operators in Detail [#]
This section gives more detail on the behaviour of the matching operators used on the left-hand side of JAPE rules.
8.2.1 Compositional Operators [#]
Compositional operators are used to combine matching constructions in the manner intended. Union and Kleene operators are available, as is range notation.
Union and Kleene Operators
The following union and Kleene operators are available:
- | - or
- * - zero or more occurrences
- ? - zero or one occurrences
- + - one or more occurrences
In the following example, you can see the ‘|’ and ‘?’ operators being used:
Rule: LocOrganization
Priority: 50 ( ({Lookup.majorType == location} | {Lookup.majorType == country_adj}) {Lookup.majorType == organization} ({Lookup.majorType == organization})? ) :orgName --> :orgName.TempOrganization = {kind = "orgName", rule=LocOrganization} |
Range Notation [#]
A range notation can also be added. e.g.
({Token})[1,3]
|
matches one to three Tokens in a row.
({Token.kind == number})[3]
|
matches exactly 3 number Tokens in a row.
8.2.2 Matching Operators [#]
Matching operators are used to specify how matching must take place between a specification and an annotation in the document. Equality (‘==’ and ‘!=’) and comparison (‘<’, ‘<=’, ‘>=’ and ‘>’) operators can be used, as can regular expression matching and contextual operators (‘contains’ and ‘within’).
Equality Operators
The equality operators are ‘==’ and ‘!=’. The basic operator in JAPE is equality. {Lookup.majorType == "person"} matches a Lookup annotation whose majorType feature has the value ‘person’. Similarly {Lookup.majorType != "person"} would match any Lookup whose majorType feature does not have the value ‘person’. If a feature is missing it is treated as if it had an empty string as its value, so this would also match a Lookup annotation that did not have a majorType feature at all.
Certain type coercions are performed:
- If the constraint’s attribute is a string, it is compared with the annotation feature value using string equality (String.equals()).
- If the constraint’s attribute is an integer it is treated as a java.lang.Long. If the annotation feature value is also a Long, or is a string that can be parsed as a Long, then it is compared using Long.equals().
- If the constraint’s attribute is a floating-point number it is treated as a java.lang.Double. If the annotation feature value is also a Double, or is a string that can be parsed as a Double, then it is compared using Double.equals().
- If the constraint’s attribute is true or false (without quotes) it is treated as a java.lang.Boolean. If the annotation feature value is also a Boolean, or is a string that can be parsed as a Boolean, then it is compared using Boolean.equals().
The != operator matches exactly when == doesn’t.
Comparison Operators
The comparison operators are ‘<’, ‘<=’, ‘>=’ and ‘>’. Comparison operators have their expected meanings, for example {Token.length > 3} matches a Token annotation whose length attribute is an integer greater than 3. The behaviour of the operators depends on the type of the constraint’s attribute:
- If the constraint’s attribute is a string it is compared with the annotation feature value using Unicode-lexicographic order (see String.compareTo()).
- If the constraint’s attribute is an integer it is treated as a java.lang.Long. If the annotation feature value is also a Long, or is a string that can be parsed as a Long, then it is compared using Long.compareTo().
- If the constraint’s attribute is a floating-point number it is treated as a java.lang.Double. If the annotation feature value is also a Double, or is a string that can be parsed as a Double, then it is compared using Double.compareTo().
Regular Expression Operators [#]
The regular expression operators are ‘=~’, ‘==~’, ‘!~’ and ‘!=~’. These operators match regular expressions. {Token.string =~ "[Dd]ogs"} matches a Token annotation whose string feature contains a substring that matches the regular expression [Dd]ogs, using !~ would match if the feature value does not contain a substring that matches the regular expression. The ==~ and !=~ operators are like =~ and !~ respectively, but require that the whole value match (or not match) the regular expression3. As with ==, missing features are treated as if they had the empty string as their value, so the constraint {Identifier.name ==~ "(?i)[aeiou]*"} would match an Identifier annotation which does not have a name feature, as well as any whose name contains only vowels.
The matching uses the standard Java regular expression library, so full details of the pattern syntax can be found in the JavaDoc documentation for java.util.regex.Pattern. There are a few specific points to note:
- To enable flags such as case-insensitive matching you can use the (?flags) notation. See the Pattern JavaDocs for details.
- If you need to include a double quote character in a regular expression you must precede it with a backslash, otherwise JAPE will give a syntax error. Quoted strings in JAPE grammars also convert the sequences \n, \r and \t to the characters newline (U+000A), carriage return (U+000D) and tab (U+0009) respectively, but these characters can match literally in regular expressions so it does not make any difference to the result in most cases.4
Contextual Operators [#]
The contextual Operators are ‘contains’ and ‘within’. These operators match annotations within the context of other annotations.
- contains - Written as {X contains Y}, returns true if an annotation of type X completely contains an annotation of type Y.
- within - Written as {X within Y}, returns true if an annotation of type X is completely covered by an annotation of type Y.
For either operator, the right-hand value (Y in the above examples) can be a full constraint itself. For example {X contains {Y.foo==bar}} is also accepted. The operators can be used in a multi-constraint statement (see Section 8.1.8) just like any of the traditional ones, so {X.f1 != "something", X contains {Y.foo==bar}} is valid.
Custom Operators [#]
It is possible to add additional custom operators without modifying the JAPE language. There are new init-time parameters to Transducer so that additional annotation ‘meta-property’ accessors and custom operators can be referenced at runtime. To add a custom operator, write a class that implements gate.jape.constraint.ConstraintPredicate, and then list that class name for the Transducer’s ‘operators’ property. Similarly, to add a custom ‘meta-property’ accessor, write a class that implements gate.jape.constraint.AnnotationAccessor, and then list that class name in the Transducer’s ‘annotationAccessors’ property.
8.3 The Right-Hand Side [#]
The RHS of the rule contains information about the annotation to be created/manipulated. Information about the text span to be annotated is transferred from the LHS of the rule using the label just described, and annotated with the entity type (which follows it). Finally, attributes and their corresponding values are added to the annotation. Alternatively, the RHS of the rule can contain Java code to create or manipulate annotations, see Section 8.6.
8.3.1 A Simple Example
In the simple example below, the pattern described will be awarded an annotation of type ‘Enamex’ (because it is an entity name). This annotation will have the attribute ‘kind’, with value ‘location’, and the attribute ‘rule’, with value ‘GazLocation’. (The purpose of the ‘rule’ attribute is simply to ease the process of manual rule validation).
Rule: GazLocation
( {Lookup.majorType == location} ) :location --> :location.Enamex = {kind="location", rule=GazLocation} |
8.3.2 Copying Feature Values from the LHS to the RHS
JAPE provides limited support for copying annotation feature values from the left to the right hand side of a rule, for example:
Rule: LocationType
( {Lookup.majorType == location} ):loc --> :loc.Location = {rule = "LocationType", type = :loc.Lookup.minorType} |
This will set the ‘type’ feature of the generated location to the value of the ‘minorType’ feature from the ‘Lookup’ annotation bound to the loc label. If the Lookup has no minorType, the Location will have no ‘type’ feature. The behaviour of newFeat = :bind.Type.oldFeat is:
- Find all the annotations of type Type from the left hand side binding bind.
- Find one of them that has a non-null value for its oldFeat feature (if there is more than one, which one is chosen is up to the JAPE implementation).
- If such a value exists, set the newFeat feature of our newly created annotation to this value.
- If no such non-null value exists, do not set the newFeat feature at all.
Notice that the behaviour is deliberately underspecified if there is more than one Type annotation in bind. If you need more control, or if you want to copy several feature values from the same left hand side annotation, you should consider using Java code on the right hand side of your rule (see Section 8.6).
8.3.3 RHS Macros
Macros, first introduced in the context of the left-hand side (Section 8.1.6) can also be used on the RHS of rules. In this case, the label (which matches the label on the LHS of the rule) should be included in the macro. Below we give an example of using a macro on the RHS:
Macro: UNDERSCORES_OKAY // separate
:match // lines { gate.AnnotationSet matchedAnns = (gate.AnnotationSet)bindings.get("match"); int begOffset = matchedAnns.firstNode().getOffset().intValue(); int endOffset = matchedAnns.lastNode().getOffset().intValue(); String mydocContent = doc.getContent().toString(); String matchedString = mydocContent.substring(begOffset, endOffset); gate.FeatureMap newFeatures = Factory.newFeatureMap(); if(matchedString.equals("Spanish")) { newFeatures.put("myrule", "Lower"); } else { newFeatures.put("myrule", "Upper"); } newFeatures.put("quality", "1"); annotations.add(matchedAnns.firstNode(), matchedAnns.lastNode(), "Spanish_mark", newFeatures); } Rule: Lower ( ({Token.string == "Spanish"}) :match)-->UNDERSCORES_OKAY // no label here, only macro name Rule: Upper ( ({Token.string == "SPANISH"}) :match)-->UNDERSCORES_OKAY // no label here, only macro name |
8.4 Use of Priority [#]
Each grammar has one of 5 possible control styles: ‘brill’, ‘all’, ‘first’, ‘once’ and ‘appelt’. This is specified at the beginning of the grammar.
The Brill style means that when more than one rule matches the same region of the document, they are all fired. The result of this is that a segment of text could be allocated more than one entity type, and that no priority ordering is necessary. Brill will execute all matching rules starting from a given position and will advance and continue matching from the position in the document where the longest match finishes.
The ‘all’ style is similar to Brill, in that it will also execute all matching rules, but the matching will continue from the next offset to the current one.
For example, where [] are annotations of type Ann
[aaa[bbb]] [ccc[ddd]]
|
then a rule matching {Ann} and creating {Ann-2} for the same spans will generate:
BRILL: [aaabbb] [cccddd]
ALL: [aaa[bbb]] [ccc[ddd]] |
With the ‘first’ style, a rule fires for the first match that’s found. This makes it inappropriate for rules that end in ‘+’ or ‘?’ or ‘*’. Once a match is found the rule is fired; it does not attempt to get a longer match (as the other two styles do).
With the ‘once’ style, once a rule has fired, the whole JAPE phase exits after the first match.
With the appelt style, only one rule can be fired for the same region of text, according to a set of priority rules. Priority operates in the following way.
- From all the rules that match a region of the document starting at some point X, the one which matches the longest region is fired.
- If more than one rule matches the same region, the one with the highest priority is fired
- If there is more than one rule with the same priority, the one defined earlier in the grammar is fired.
An optional priority declaration is associated with each rule, which should be a positive integer. The higher the number, the greater the priority. By default (if the priority declaration is missing) all rules have the priority -1 (i.e. the lowest priority).
For example, the following two rules for location could potentially match the same text.
Rule: Location1
Priority: 25 ( ({Lookup.majorType == loc_key, Lookup.minorType == pre} {SpaceToken})? {Lookup.majorType == location} ({SpaceToken} {Lookup.majorType == loc_key, Lookup.minorType == post})? ) :locName --> :locName.Location = {kind = "location", rule = "Location1"} Rule: GazLocation Priority: 20 ( ({Lookup.majorType == location}):location ) --> :location.Name = {kind = "location", rule=GazLocation} |
Assume we have the text ‘China sea’, that ‘China’ is defined in the gazetteer as ‘location’, and that sea is defined as a ‘loc_key’ of type ‘post’. In this case, rule Location1 would apply, because it matches a longer region of text starting at the same point (‘China sea’, as opposed to just ‘China’). Now assume we just have the text ‘China’. In this case, both rules could be fired, but the priority for Location1 is highest, so it will take precedence. In this case, since both rules produce the same annotation, so it is not so important which rule is fired, but this is not always the case.
One important point of which to be aware is that prioritisation only operates within a single grammar. Although we could make priority global by having all the rules in a single grammar, this is not ideal due to other considerations. Instead, we currently combine all the rules for each entity type in a single grammar. An index file (main.jape) is used to define which grammars should be used, and in which order they should be fired.
Note also that depending on the control style, firing a rule may ‘consume’ that part of the text, making it unavailable to be matched by other rules. This can be a problem for example if one rule uses context to make it more specific, and that context is then missed by later rules, having been consumed due to use of for example the ‘Brill’ control style. ‘All’, on the other hand, would allow it to be matched.
Using priority to resolve ambiguity
If the Appelt style of matching is selected, rule priority operates in the following way.
- Length of rule – a rule matching a longer pattern will fire first.
- Explicit priority declaration. Use the optional Priority function to assign a ranking. The higher the number, the higher the priority. If no priority is stated, the default is -1.
- Order of rules. In the case where the above two factors do not distinguish between two rules, the order in which the rules are stated applies. Rules stated first have higher priority.
Because priority can only operate within a single grammar, this can be a problem for dealing with ambiguity issues. One solution to this is to create a temporary set of annotations in initial grammars, and then manipulate this temporary set in one or more later phases (for example, by converting temporary annotations from different phases into permanent annotations in a single final phase). See the default set of grammars for an example of this.
If two possible ways of matching are found for the same text string, a conflict can arise. Normally this is handled by the priority mechanism (test length, rule priority and finally rule precedence). If all these are equal, Jape will simply choose a match at random and fire it. This leads ot non-deterministic behaviour, which should be avoided.
8.5 Using Phases Sequentially [#]
A JAPE grammar consists of a set of sequential phases. The list of phases is specified (in the order in which they are to be run) in a file, conventionally named main.jape. When loading the grammar into GATE, it is only necessary to load this main file – the phases will then be loaded automatically. It is, however, possible to omit this main file, and just load the phases individually, but this is much more time-consuming. The grammar phases do not need to be located in the same directory as the main file, but if they are not, the relative path should be specified for each phase.
One of the main reasons for using a sequence of phases is that a pattern can only be used once in each phase, but it can be reused in a later phase. Combined with the fact that priority can only operate within a single grammar, this can be exploited to help deal with ambiguity issues.
The solution currently adopted is to write a grammar phase for each annotation type, or for each combination of similar annotation types, and to create temporary annotations. These temporary annotations are accessed by later grammar phases, and can be manipulated as necessary to resolve ambiguity or to merge consecutive annotations. The temporary annotations can either be removed later, or left and simply ignored.
Generally, annotations about which we are more certain are created earlier on. Annotations which are more dubious may be created temporarily, and then manipulated by later phases as more information becomes available.
An annotation generated in one phase can be referred to in a later phase, in exactly the same way as any other kind of annotation (by specifying the name of the annotation within curly braces). The features and values can be referred to or omitted, as with all other annotations. Make sure that if the Input specification is used in the grammar, that the annotation to be referred to is included in the list.
8.6 Using Java Code on the RHS [#]
The RHS of a JAPE rule can consist of any Java code. This is useful for removing temporary annotations and for percolating and manipulating features from previous annotations. In the example below
The first rule below shows a rule which matches a first person name, e.g. ‘Fred’, and adds a gender feature depending on the value of the minorType from the gazetteer list in which the name was found. We first get the bindings associated with the person label (i.e. the Lookup annotation). We then create a new annotation called ‘personAnn’ which contains this annotation, and create a new FeatureMap to enable us to add features. Then we get the minorType features (and its value) from the personAnn annotation (in this case, the feature will be ‘gender’ and the value will be ‘male’), and add this value to a new feature called ‘gender’. We create another feature ‘rule’ with value ‘FirstName’. Finally, we add all the features to a new annotation ‘FirstPerson’ which attaches to the same nodes as the original ‘person’ binding.
Note that inputAS and outputAS represent the input and output annotation set. Normally, these would be the same (by default when using ANNIE, these will be the ‘Default’ annotation set). Since the user is at liberty to change the input and output annotation sets in the paramters of the JAPE transducer at runtime, it cannot be guaranteed that the input and output annotation sets will be the same, and therefore we must specify the annotation set we are referring to.
Rule: FirstName
( {Lookup.majorType == person_first} ):person --> { gate.AnnotationSet person = (gate.AnnotationSet)bindings.get("person"); gate.Annotation personAnn = (gate.Annotation)person.iterator().next(); gate.FeatureMap features = Factory.newFeatureMap(); features.put("gender", personAnn.getFeatures().get("minorType")); features.put("rule", "FirstName"); outputAS.add(person.firstNode(), person.lastNode(), "FirstPerson", features); } |
The second rule (contained in a subsequent grammar phase) makes use of annotations produced by the first rule described above. Instead of percolating the minorType from the annotation produced by the gazetteer lookup, this time it percolates the feature from the annotation produced by the previous grammar rule. So here it gets the ‘gender’ feature value from the ‘FirstPerson’ annotation, and adds it to a new feature (again called ‘gender’ for convenience), which is added to the new annotation (in outputAS) ‘TempPerson’. At the end of this rule, the existing input annotations (from inputAS) are removed because they are no longer needed. Note that in the previous rule, the existing annotations were not removed, because it is possible they might be needed later on in another grammar phase.
Rule: GazPersonFirst
( {FirstPerson} ) :person --> { gate.AnnotationSet person = (gate.AnnotationSet)bindings.get("person"); gate.Annotation personAnn = (gate.Annotation)person.iterator().next(); gate.FeatureMap features = Factory.newFeatureMap(); features.put("gender", personAnn.getFeatures().get("gender")); features.put("rule", "GazPersonFirst"); outputAS.add(person.firstNode(), person.lastNode(), "TempPerson", features); inputAS.removeAll(person); } |
8.6.1 A More Complex Example
The example below is more complicated, because both the title and the first name (if present) may have a gender feature. There is a possibility of conflict since some first names are ambiguous, or women are given male names (e.g. Charlie). Some titles are also ambiguous, such as ‘Dr’, in which case they are not marked with a gender feature. We therefore take the gender of the title in preference to the gender of the first name, if it is present. So, on the RHS, we first look for the gender of the title by getting all Title annotations which have a gender feature attached. If a gender feature is present, we add the value of this feature to a new gender feature on the Person annotation we are going to create. If no gender feature is present, we look for the gender of the first name by getting all firstPerson annotations which have a gender feature attached, and adding the value of this feature to a new gender feature on the Person annotation we are going to create. If there is no firstPerson annotation and the title has no gender information, then we simply create the Person annotation with no gender feature.
Rule: PersonTitle
Priority: 35 /* allows Mr. Jones, Mr Fred Jones etc. */ ( (TITLE) (FIRSTNAME | FIRSTNAMEAMBIG | INITIALS2)* (PREFIX)? {Upper} ({Upper})? (PERSONENDING)? ) :person --> { gate.FeatureMap features = Factory.newFeatureMap(); gate.AnnotationSet personSet = (gate.AnnotationSet)bindings.get("person"); // get all Title annotations that have a gender feature HashSet fNames = new HashSet(); fNames.add("gender"); gate.AnnotationSet personTitle = personSet.get("Title", fNames); // if the gender feature exists if (personTitle != null && personTitle.size()>0) { gate.Annotation personAnn = (gate.Annotation)personTitle.iterator().next(); features.put("gender", personAnn.getFeatures().get("gender")); } else { // get all firstPerson annotations that have a gender feature gate.AnnotationSet firstPerson = personSet.get("FirstPerson", fNames); if (firstPerson != null && firstPerson.size()>0) // create a new gender feature and add the value from firstPerson { gate.Annotation personAnn = (gate.Annotation)firstPerson.iterator().next(); features.put("gender", personAnn.getFeatures().get("gender")); } } // create some other features features.put("kind", "personName"); features.put("rule", "PersonTitle"); // creat a Person annotation and add the features we’ve created outputAS.add(personSet.firstNode(), personSet.lastNode(), "TempPerson", features); } |
8.6.2 Adding a Feature to the Document [#]
This is useful when using conditional controllers, where we only want to fire a particular resource under certain conditions. We first test the document to see whether it fulfils these conditions or not, and attach a feature to the document accordingly.
In the example below, we test whether the document contains an annotation of type ‘message’. In emails, there is often an annotation of this type (produced by the document format analysis when the document is loaded in GATE). Note that annotations produced by document format analysis are placed automatically in the ‘Original markups’ annotation set, so we must ensure that when running the processing resource containing this grammar that we specify the Original markups set as the input annotation set. It does not matter what we specify as the output annotation set, because the annotation we produce is going to be attached to the document and not to an output annotation set. In the example, if an annotation of type ‘message’ is found, we add the feature ‘genre’ with value ‘email’ to the document.
Rule: Email
Priority: 150 ( {message} ) --> { doc.getFeatures().put("genre", "email"); } |
8.6.3 Finding the Tokens of a Matched Annotation [#]
In this section we will demonstrate how by using Java on the right-hand side one can find all Token annotations that are covered by a matched annotation, e.g., a Person or an Organization. This is useful if one wants to transfer some information from the matched annotations to the tokens. For example, to add to the Tokens a feature indicating whether or not they are covered by a named entity annotation deduced by the rule-based system. This feature can then be given as a feature to a learning PR, e.g. the HMM. Similarly, one can add a feature to all tokens saying which rule in the rule based system did the match, the idea being that some rules might be more reliable than others. Finally, yet another useful feature might be the length of the coreference chain in which the matched entity is involved, if such exists.
The example below is one of the pre-processing JAPE grammars used by the HMM application. To inspect all JAPE grammars, see the muse/applications/hmm directory in the distribution.
Phase: NEInfo
Input: Token Organization Location Person Options: control = appelt Rule: NEInfo Priority:100 ({Organization} | {Person} | {Location}):entity --> { //get the annotation set gate.AnnotationSet annSet = ((gate.AnnotationSet)bindings.get("entity")); //get the only annotation from the set gate.Annotation entityAnn = (gate.Annotation)annSet.iterator().next(); gate.AnnotationSet tokenAS = inputAS.get("Token", entityAnn.getStartNode().getOffset(), entityAnn.getEndNode().getOffset()); List tokens = new ArrayList(tokenAS); //if no tokens to match, do nothing if (tokens.isEmpty()) return; Collections.sort(tokens, new gate.util.OffsetComparator()); gate.Annotation curToken=null; for (int i=0; i < tokens.size(); i++) { curToken = (gate.Annotation) tokens.get(i); String ruleInfo = (String) entityAnn.getFeatures().get("rule1"); String NMRuleInfo = (String) entityAnn.getFeatures().get("NMRule"); if ( ruleInfo != null) { curToken.getFeatures().put("rule_NE_kind", entityAnn.getType()); curToken.getFeatures().put("NE_rule_id", ruleInfo); } else if (NMRuleInfo != null) { curToken.getFeatures().put("rule_NE_kind", entityAnn.getType()); curToken.getFeatures().put("NE_rule_id", "orthomatcher"); } else { curToken.getFeatures().put("rule_NE_kind", "None"); curToken.getFeatures().put("NE_rule_id", "None"); } List matchesList = (List) entityAnn.getFeatures().get("matches"); if (matchesList != null) { if (matchesList.size() == 2) curToken.getFeatures().put("coref_chain_length", "2"); else if (matchesList.size() > 2 && matchesList.size() < 5) curToken.getFeatures().put("coref_chain_length", "3-4"); else curToken.getFeatures().put("coref_chain_length", "5-more"); } else curToken.getFeatures().put("coref_chain_length", "0"); }//for } Rule: TokenNEInfo Priority:10 ({Token}):entity --> { //get the annotation set gate.AnnotationSet annSet = ((gate.AnnotationSet)bindings.get("entity")); //get the only annotation from the set gate.Annotation entityAnn = (gate.Annotation)annSet.iterator().next(); entityAnn.getFeatures().put("rule_NE_kind", "None"); entityAnn.getFeatures().put("NE_rule_id", "None"); entityAnn.getFeatures().put("coref_chain_length", "0"); } |
8.6.4 Using Named Blocks [#]
For the common case where a Java block refers just to the annotations from a single left-hand-side binding, JAPE provides a shorthand notation:
Rule: RemoveDoneFlag
( {Instance.flag == "done"} ):inst --> :inst{ Annotation theInstance = (Annotation)instAnnots.iterator().next(); theInstance.getFeatures().remove("flag"); } |
This rule is equivalent to the following:
Rule: RemoveDoneFlag
( {Instance.flag == "done"} ):inst --> { AnnotationSet instAnnots = (AnnotationSet)bindings.get("inst"); if(instAnnots != null && instAnnots.size() != 0) { Annotation theInstance = (Annotation)instAnnots.iterator().next(); theInstance.getFeatures().remove("flag"); } } |
A label :<label> on a Java block creates a local variable <label>Annots within the Java block which is the AnnotationSet bound to the <label> label. Also, the Java code in the block is only executed if there is at least one annotation bound to the label, so you do not need to check this condition in your own code. Of course, if you need more flexibility, e.g. to perform some action in the case where the label is not bound, you will need to use an unlabelled block and perform the bindings.get() yourself.
8.6.5 Java RHS Overview [#]
When a JAPE grammar is parsed, a Jape parser creates action classes for all Java RHSs in the grammar. (one action class per RHS) RHS Java code will be embedded as a body of the method doIt and will work in context of this method. When a particular rule is fired, the method doIt will be executed.
Method doIt is specified by the interface gate.jape.RhsAction. Each action class implements this interface and is generated with the following template:
1import java.io.*;
2import java.util.*;
3import gate.*;
4import gate.jape.*;
5import gate.creole.ontology.*;
6import gate.annotation.*;
7import gate.util.*;
8class <AutogeneratedActionClassName>
9 implements java.io.Serializable, gate.jape.RhsAction {
10 public void doIt(gate.Document doc,
11 java.util.Map bindings,
12 gate.AnnotationSet annotations,
13 gate.AnnotationSet inputAS,
14 gate.AnnotationSet outputAS,
15 gate.creole.ontology.Ontology ontology)
16 throws JapeException {
17 // your RHS Java code will be embedded here ...
18 }
19}
Method doIt has the following parameters that can be used in RHS Java code:
- gate.Document doc - a document that is currently processed
- java.util.Map bindings - a map of binding variables where a key is a (String) name of binding variable and value is (AnnotationSet) set of annotations corresponding to this binding variable
- gate.AnnotationSet annotations - Do not use this (it’s a synonym for outputAS that is still used in some grammars but is now deprecated).
- gate.AnnotationSet inputAS - input annotations
- gate.AnnotationSet outputAS - output annotations
- gate.creole.ontology.Ontology ontology - a GATE’s transducer ontology
In your Java RHS you can use short names for all Java classes that are imported by the action class (plus Java classes from the packages that are imported by default according to JVM specification: java.lang.*, java.math.*). But you need to use fully qualified Java class names for all other classes. For example:
-->
{ // VALID line examples AnnotationSet as = ... InputStream is = ... java.util.logging.Logger myLogger = java.util.logging.Logger.getLogger("JAPELogger"); java.sql.Statement stmt = ... // INVALID line examples Logger myLogger = Logger.getLogger("JapePhaseLogger"); Statement stmt = ... } |
In order to add additional Java import statements to all Java RHS’ of the rules in a JAPE grammar file, you can use the following code at the beginning of the JAPE file:
Imports: {
import java.util.logging.Logger; import java.sql.*; } |
These import statements will be added to the default import statements for each action class generated for a RHS and the corresponding classes can be used in the RHS Java code without the need to use fully qualified names.
8.7 Optimising for Speed [#]
The way in which grammars are designed can have a huge impact on the processing speed. Some simple tricks to keep the processing as fast as possible are:
- avoid the use of the * and + operators. Replace them with range queries where possible. For
example, instead of
({Token})*
use
({Token})[0,3]if you can predict that you won’t need to recognise a string of Tokens longer than 3.
- avoid specifying unnecessary elements such as SpaceTokens where you can. To do this, use the Input specification at the beginning of the grammar to stipulate the annotations that need to be considered. If no Input specification is used, all annotations will be considered (so, for example, you cannot match two tokens separated by a space unless you specify the SpaceToken in the pattern). If, however, you specify Tokens but not SpaceTokens in the Input, SpaceTokens do not have to be mentioned in the pattern to be recognised. If, for example, there is only one rule in a phase that requires SpaceTokens to be specified, it may be judicious to move that rule to a separate phase where the SpaceToken can be specified as Input.
- avoid the shorthand syntax for copying feature values (newFeat = :bind.Type.oldFeat), particularly if you need to copy multiple features from the left to the right hand side of your rule.
8.8 Ontology Aware Grammar Transduction [#]
GATE supports two different methods for ontology aware grammar transduction. Firstly it is possible to use the ontology feature both in grammars and annotations, while using the default transducer. Secondly it is possible to use an ontology aware transducer by passing an ontology language resource to one of the subsumes methods in SimpleFeatureMapImpl. This second strategy does not check for ontology features, which will make the writing of grammars easier, as there is no need to specify ontology when writing them. More information about the ontology-aware transducer can be found in Section 14.9.
8.9 Serializing JAPE Transducer [#]
JAPE grammars are written as files with the extension ‘.jape’, which are parsed and compiled at run-time to execute them over the GATE document(s). Serialization of the JAPE Transducer adds the capability to serialize such grammar files and use them later to bootstrap new JAPE transducers, where they do not need the original JAPE grammar file. This allows people to distribute the serialized version of their grammars without disclosing the actual contents of their jape files. This is implemented as part of the JAPE Transducer PR. The following sections describe how to serialize and deserialize them.
8.9.1 How to Serialize?
Once an instance of a JAPE transducer is created, the option to serialize it appears in the option menu of that instance. The option menu can be activated by right clicking on the respective PR. Having done so, it asks for the file name where the serialized version of the respective JAPE grammar is stored.
8.9.2 How to Use the Serialized Grammar File?
The JAPE Transducer now also has an init-time parameter binaryGrammarURL, which appears as an optional parameter to the grammarURL. The User can use this parameter (i.e. binaryGrammarURL) to specify the serialized grammar file.
8.10 The JAPE Debugger [#]
As of Version 5.1 the Jape debugger is not supported.
8.11 Notes for Montreal Transducer Users [#]
In June 2008, the standard JAPE transducer implementation gained a number of features inspired by Luc Plamondon’s ‘Montreal Transducer’, which was available as a GATE plugin for several years, and was made obsolete in Version 5.1. If you have existing Montreal Transducer grammars and want to update them to work with the standard JAPE implementation you should be aware of the following differences in behaviour:
- Quantifiers (*, + and ?) in the Montreal transducer are always greedy, but this is not necessarily the case in standard JAPE.
- The Montreal Transducer defines {Type.feature != value} to be the same as {!Type.feature == value} (and likewise the !~ operator in terms of =~). In standard JAPE these constructs have different semantics. {Type.feature != value} will only match if there is a Type annotation whose feature feature does not have the given value, and if it matches it will bind the single Type annotation. {!Type.feature == value} will match if there is no Type annotation at a given place with this feature (including when there is no Type annotation at all), and if it matches it will bind every other annotation that starts at that location. If you have used != in your Montreal grammars and want them to continue to behave the same way you must change them to use the prefix-! form instead (see Section 8.1.9).
- The =~ operator in standard JAPE looks for regular expression matches anywhere within a feature value, whereas in the Montreal transducer it requires the whole string to match. To obtain the whole-string matching behaviour in standard JAPE, use the ==~ operator instead (see Section 8.2.2).
1A good description of the original version of this language is in Doug Appelt’s TextPro manual. Doug was a great help to us in implementing JAPE. Thanks Doug!
2In the Montreal transducer, the two forms were equivalent
3This syntax will be familiar to Groovy users.
4However this does mean that it is not possible to include an n, r or t character after a backslash in a JAPE quoted string, or to have a backslash as the last character of your regular expression. Workarounds include placing the backslash in a character class ([\\]—) or enabling the (?x) flag, which allows you to put whitespace between the backslash and the offending character without changing the meaning of the pattern.