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 INumberParserConfiguration
interface.
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 INumberParserConfiguration
interface 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