Writing a Recognizer

Last month, we announced new number recognizers for LUIS and that we were releasing them open source.

In this article, we’ll go over the structure and core concepts of the Recognizers-Text library so that you can build your own!

NOTE: We’ll be using the English Number Recognizer to show you the internal workflow.

Understanding Recognizers

First we’ll examine the IModel interface which defines a contract as shown:

public interface IModel
{
    string ModelTypeName { get; }

    List<ModelResult> Parse(string query);
}

Only one method, Parse is required to implement per the code contract. To understand this method we can take a look at the AbstractNumberModel class where the contract is implemented:

public abstract class AbstractNumberModel : IModel
{
    public AbstractNumberModel(IParser parser, IExtractor extractor) {...}

    public List<ModelResult> Parse(string query)
    {
        var extractResults = Extractor.Extract(query);
        var parseNums = new List<ParseResult>();
        foreach (var result in extractResults)
        {
            parseNums.Add(Parser.Parse(result));
        }
        return parseNums.Select(o => new ModelResult
        {
            Start = o.Start.Value,
            End = o.Start.Value + o.Length.Value - 1,
            Resolutions = new SortedDictionary<string, string> value,
            Text = o.Text,
            TypeName = ModelTypeName
        }).ToList();
    }
}

This class is responsible for parsing a user query from LUIS and determining which type of number (number, ordinal, percentage) to map. Once the type of number entity is determined, we can create the number model in two steps:

  • Extract the entity or entities from the input text.
  • Parse each entity to some value that we can handle.

So, the key concepts to understand how the recognizers work is the implementation of the Extractors and Parsers. The Extractor and Parser components depend on what kind of number entities we want to recognize from a query.

Extractors

All extractors will inherit from a BaseNumberExtractor which includes an an interface of IExtractor as shown below:

public interface IExtractor
{
    List<ExtractResult> Extract(string input);
}

The abstract class BaseNumberExtractor already includes a common implementation for the number extractor in the Extract method which you can use as a base.

The extractor classes only needs to implement a dictionary with the regexes that will match the desired entity structure. The sample code from the repo provides a extractors for cardinals, doubles, fractions, integers, numbers, ordinals and percentages by default; which are all types of numbers we may want to extract from the user query. Each one of these classes defines its’ own dictionary of regexes, and populates the dictionary with new regular expressions in the constructor.

Taking a look at the English DoubleExtractor class as an example:

public class DoubleExtractor : BaseNumberExtractor
{
    internal sealed override ImmutableDictionary<Regex, string> Regexes { get; }
    protected sealed override string ExtractType { get; } = Constants.SYS_NUM_DOUBLE; // "Double";

    public static string AllPointRegex
        => $@"((\s+{IntegerExtractor.ZeroToNineIntegerRegex})+|(\s+{IntegerExtractor.SeparaIntRegex}))";

    public static string AllFloatRegex => $@"{IntegerExtractor.AllIntRegex}(\s+point){AllPointRegex}";

    public DoubleExtractor(string placeholder = @"\D|\b")
    {
        var _regexes = new Dictionary<Regex, string>
        {
            {
                new Regex($@"(((?<!\d+\s*)-\s*)|((?<=\b)(?<!\d+\.)))\d+\.\d+(?!(\.\d+))(?={placeholder})",
                    RegexOptions.Compiled|RegexOptions.IgnoreCase | RegexOptions.Singleline),
                "DoubleNum"
            }
            ...
            {
                new Regex(
                    $@"(((?<!\d+\s*)-\s*)|((?<=\b)(?<!\d+\.)))\d+\.\d+\s+{IntegerExtractor.RoundNumberIntegerRegex}(?=\b)",
                    RegexOptions.Compiled|RegexOptions.IgnoreCase | RegexOptions.Singleline),
                "DoubleNum"
            }
            ...
            {
                new Regex(@"(((?<!\d+\s*)-\s*)|((?<=\b)(?<!\d+\.)))(\d+(\.\d+)?)\^([+-]*[1-9]\d*)(?=\b)",
                    RegexOptions.Compiled|RegexOptions.IgnoreCase | RegexOptions.Singleline),
                "DoublePow"
            }
        };

        this.Regexes = _regexes.ToImmutableDictionary();
    }
}

In this implementation, we build the dictionary with the regexes that will match with the English Double Number format.

Here we can also note that the extractor makes use of the regexes of the IntegerExtractor(IntegerExtractor.ZeroToNineIntegerRegex and IntegerExtractor.AllIntRegex). So the extractors can be combined with one other to evaluate more complex entities. For example, the NumberExtractor uses the regexes of the cardinal and fractional extractors and the CardinalExtractor use the regexes of the Integer and Double extractors.

Additionally, for the implementation of the NumberExtractor we have the ability to select the kind of number we will expect to extract, currently we made the distinction of pure numbers, currency related numbers and anything else.

public NumberExtractor(NumberMode mode = NumberMode.Default)
{
    var builder = ImmutableDictionary.CreateBuilder<Regex, string>();
    //Add Cardinal
    CardinalExtractor cardExtract = null;
    switch (mode)
    {
        case NumberMode.PureNumber:
            cardExtract = new CardinalExtractor(@"\b");
            break;
        case NumberMode.Currency:
            builder.Add(new Regex(@"(((?<=\W|^)-\s*)|(?<=\b))\d+\s*(B|b|m|t|g)(?=\b)", RegexOptions.Compiled | RegexOptions.Singleline),
                "IntegerNum");
            break;
        case NumberMode.Default:
            break;
    }

After we use the extractor we are left with string values which we still need to parse.

Parsers

Like extractors, parsers also require an interface:

public interface IParser
{
    ParseResult Parse(ExtractResult extResult);
}

We can see this interface IParser in the class BaseNumberParser. This special parser is meant to be language-agnostic, and can be used for several different languages by simply providing the proper language parser configuration which will provide the unique logic specific to the language which you’re writing recognizers for. In the following example, we define an EnglishNumberParserConfiguration which implements the INumberParserConfigurationinterface.

namespace Microsoft.Recognizers.Text.Number.English.Parsers
{
    public class EnglishNumberParserConfiguration : INumberParserConfiguration
    ...

In this class we configure the CultureInfo and various particular tokens (Decimal separator, Fraction separator, Half-dozen identifier) unique to the language’s logic.

    ...
public EnglishNumberParserConfiguration(CultureInfo ci)
{
    this.LangMarker = "Eng";
    this.CultureInfo = ci;

    this.DecimalSeparatorChar = '.';
    this.FractionMarkerToken = "over";
    this.NonDecimalSeparatorChar = ',';
    this.HalfADozenText = "six";
    this.WordSeparatorToken = "and";
...

In the english parser configuration, we provide three static dictionaries for entity mapping to ordinals, cardinals, and round numbers. If you are creating number recognizers in another language, this class is where the bulk of your custom logic to handle the specifics of the language will be.

    ...
private static ImmutableDictionary<string, long> InitOrdinalNumberMap()
{
    return new Dictionary<string, long>
    {
        {"first", 1},
        {"second", 2},
        {"secondary", 2},
        {"half", 2},
        {"third", 3},
        ...
        {"billionth", 1000000000},
    };
    ..
    return new Dictionary<string, long>(simpleOrdinalDictionary).ToImmutableDictionary();
}

private static ImmutableDictionary<string, long> InitCardinalNumberMap()
{
    return new Dictionary<string, long>
    {
        {"a", 1},
        {"zero", 0},
        {"an", 1},
        {"one", 1},
        {"two", 2},
        {"three", 3},
        ...
        {"trillion", 1000000000000}
    }.ToImmutableDictionary();
}

private static ImmutableDictionary<string, long> InitRoundNumberMap()
{
    return new Dictionary<string, long>
    {
        {"hundred", 100},
        {"thousand", 1000},
        {"million", 1000000},
        {"billion", 1000000000},
        {"trillion", 1000000000000},
        ...
        {"g", 1000000000},
        {"t", 1000000000000}
    }.ToImmutableDictionary();
}
    ...

What happens when a particular value doesn’t directly map to our static dictionaries? In case the dictionaries are not enough to map all extracted entities, the INumberParserConfigurationinterface also provides a function ResolveCompositeNumber to normalize the input obtained from the extractor and another function to resolve complex numbers that cannot be directly mapped as shown below.

...
    /// <summary>
    /// Used when requiring to normalize a token to a valid expression supported by the ImmutableDictionaries (language dictionaries)
    /// </summary>
    /// <param name="tokens">list of tokens to normalize</param>
    /// <param name="context">context of the call</param>
    /// <returns>list of normalized tokens</returns>
    IEnumerable<string> NormalizeTokenSet(IEnumerable<string> tokens, object context);

    /// <summary>
    /// Used when requiring to convert a string to a valid number supported by the language
    /// </summary>
    /// <param name="numberStr">composite number</param>
    /// <returns>value of the string</returns>
    long ResolveCompositeNumber(string numberStr);
...

Once the language-specific parser configuration is defined (example: SpanishNumberConfiguration.cs), we are ready to use the BaseNumberParser. To make it easier to get the Parser, the library has an AgnosticNumberParserFactory which only needs the Type of the Parser and the proper instance of INumberParserConfiguration.

Testing

Now that we have defined extractors and parsers, we can test to verify that our recognizers work. In this example, we show the initialization of the English Number Recognizer for ordinal numbers:

var model = new OrdinalModel(
    AgnosticNumberParserFactory.GetParser(AgnosticNumberParserType.Ordinal, new EnglishNumberParserConfiguration()), 
    new OrdinalExtractor());

var inputText = "fory second"; // forty-second
var result = model.Parse(inputText);

var outputText = result[0].Resolutions["value"] // 42

In a nutshell, to write a recognizer for another language, you only need to define your own extractors which will use the BaseNumberExtractor interface and supply your own regular expressions unique to the language. Then, define a LanguageNumberParserConfiguration class which will use the INumberParserConfiguration interface along with custom logic and properties specific to the language.

Hopefully we’ve provided you with enough of a roadmap to help you get started creating your own recognizers.

Happy Making!

The Bot Framework Team