UNPKG

wink-ner

Version:

Language agnostic named entity recognizer

718 lines (680 loc) 27.3 kB
// wink-ner // Language agnostic named entity recognizer // // Copyright (C) 2017-20 GRAYPE Systems Private Limited // // This file is part of “wink-ner”. // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included // in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. // var singularize = require( 'wink-lemmatizer' ).lemmatizeNoun; var helpers = require( 'wink-helpers' ); // ### ner /** * * Creates an instance of {@link NER}. * * @return {NER} object conatining set of API methods for named entity * training, recognition, etc. * @example * // Load wink ner. * var ner = require( 'wink-ner' ); * // Create your instance of wink ner. * var myNER = ner(); */ var ner = function () { /** * @classdesc NER class * @class NER * @hideconstructor */ var methods = Object.create( null ); // Configuration - what tags or values to ignore during recognition phase. var cfg = Object.create( null ); // Learning from single word entities and the first word of multi-word entities // goes here. For multi-words entity, the first word goes here; and the word count // is pushed into a words-count array. This array may have multiple entries if // the first word is common between multiple wluti-word entities. Similarly // if the first word is shared between a single word and multi-word entities, // then this will contain the properties of the single word entity also. // The array of word-counts is sorted in descending order — thus giving preference // to longest word-length entity during detection. var uniWordEntities = Object.create( null ); // Learning from multiple word entities goes here. var multiWordEntities = Object.create( null ); // ### defineConfig /** * * Defines the criteria to ignore one or more * [**tokens**](http://winkjs.org/wink-tokenizer/) during entity detection. * The criteria is specified in terms of array of specific **tags** and/or **values** * to ignore; this means if any of the listed tag or value is found in a token, * it is ignored and it’s value is not considered during entity recognition. * * For example by including `punctuation` in the array of tags to ignore, * tokens containing punctuations like `-` or `.` will be skipped. This will * result in recognition of **kg** and **k.g.** as **kg** (kilogram symbol) * or **Guinea-Bissau** and **Guinea Bissau** as **Guinea-Bissau** * (a country in West Africa). * * @method NER#defineConfig * @param {object} config — defines the `values` and/or `tags` to be ignore * during entity detection. Note if the match occurs in any one of the array, * the token is ignored. * * *An empty config object is equivalent to setting default configuration.* * * The table below details the properties of `config` object: * @param {string[]} [config.valuesToIgnore=undefined] contains **values** * to be ignored. * @param {string[]} [config.tagsToIgnore=[ 'punctuation' ]] contains **tags** * to be ignored. Duplicate and invaid tags, if any, are ignored. * Note: `number` and `word` tags can never be ignored. * @param {string[]} [config.ignoreDiacritics=true] a `true` ensures that diacritic * marks are ignored, whereas `false` will ensure that they are not ignored. * @return {object} a copy of configuration defined. * @throws {error} if `valuesToIgnore` is not an array of strings. * @throws {error} if `tagsToIgnore` is not an array of strings. * @example * // Do not ignore anything! * myNER.defineConfig( { tagsToIgnore: [], ignoreDiacritics: false } ); * // -> { tagsToIgnore: [], valuesToIgnore: [], ignoreDiacritics: false } * * // Ignore only '-' and '.' * myNER.defineConfig( { * tagsToIgnore: [], * valuesToIgnore: [ '-', '.' ], * ignoreDiacritics: false * } ); * // -> { * // tagsToIgnore: [], * // valuesToIgnore: [ '-', '.' ], * // ignoreDiacritics: false * // } */ var defineConfig = function ( config ) { // Check if `config` is a valid object. if ( !helpers.validate.isObject( config ) ) { throw Error( 'wink-ner/defineConfig: config must be an object, instead found: ' + JSON.stringify( config ) ); } // Check if `valuesToIgnore` is an array provided it is a truthy. if ( config.valuesToIgnore && !helpers.validate.isArray( config.valuesToIgnore ) ) { throw Error( 'wink-ner/defineConfig: valuesToIgnore must be an array, instead found: ' + JSON.stringify( config.valuesToIgnore ) ); } // Check if `tagsToIgnore` is an array provided it is a truthy. if ( config.tagsToIgnore && !helpers.validate.isArray( config.tagsToIgnore ) ) { throw Error( 'wink-ner/defineConfig: tagsToIgnore must be an array, instead found: ' + JSON.stringify( config.tagsToIgnore ) ); } var validTags = Object.create( null ); // Valid tags to exclude `number` and `word` tags. validTags.emoticon = true; validTags.email = true; validTags.emoji = true; validTags.hashtag = true; validTags.mention = true; validTags.quoted_phrase = true; // eslint-disable-line camelcase validTags.currency = true; validTags.time = true; validTags.url = true; validTags.unknown = true; validTags.symbol = true; cfg.tagsToIgnore = Object.create( null ); cfg.valuesToIgnore = Object.create( null ); if ( Object.keys( config ).length === 0 ) { // Empty `config` means, restore default configuration! cfg.tagsToIgnore.punctuation = true; cfg.ignoreDiacritics = true; } else { // Configure diacritics. if ( config.ignoreDiacritics !== undefined ) cfg.ignoreDiacritics = !!config.ignoreDiacritics; // Configure tags to ignore. let ignore = config.tagsToIgnore; if ( ignore ) { for ( let i = 0; i < ignore.length; i += 1 ) { const key = ignore[ i ]; if ( key && ( typeof key !== 'string' ) ) { throw Error( 'wink-ner/defineConfig: tagsToIgnore must contain strings, instead found: ' + JSON.stringify( ignore ) ); } // Include only valid tags; others are ignored. if ( validTags[ key ] ) cfg.tagsToIgnore[ key ] = true; } } // if ( !ignore )... // Configure values to ignore. ignore = config.valuesToIgnore; if ( ignore ) { for ( let i = 0; i < ignore.length; i += 1 ) { const key = ignore[ i ]; if ( key && ( typeof key !== 'string' ) ) { throw Error( 'wink-ner/defineConfig: valuesToIgnore must contain strings, instead found: ' + JSON.stringify( ignore ) ); } cfg.valuesToIgnore[ key ] = true; } } // if ( !ignore )... } return { tagsToIgnore: Object.keys( cfg.tagsToIgnore), valuesToIgnore: Object.keys( cfg.valuesToIgnore ), ignoreDiacritics: cfg.ignoreDiacritics }; }; // defineConfig() // ### cloneEntity /** * * Clones the input entity — `e` after excluding it's `text` property. * * @method NER#cloneEntity * @param {object} e — to be cloned. * @return {object} the clone. * @private */ var cloneEntity = function ( e ) { var clone = Object.create( null ); for ( const key in e ) { if ( key !== 'text' ) clone[ key ] = e[ key ]; } return clone; }; // cloneEntity() // ### copyKVPs /** * * Assign all the key/value pairs of `source` to `target`, excluding **wordCounts**. * * @method NER#copyKVPs * @param {object} target — to which key/value pairs from source are copied. * @param {object} source — of key/value pairs. * @return {object} the updated `target`. * @private */ var copyKVPs = function ( target, source ) { // for ( var key in source ) { if ( key !== 'wordCounts' ) target[ key ] = source[ key ]; } return target; }; // copyKVPs() // ### normalize /** * * Normalizes the input according to the diacritic configuration. * * @method NER#normalize * @param {string} value — to be normalized. * @return {string} normalized `value.` * @private */ var normalize = function ( value ) { return ( ( cfg.ignoreDiacritics ) ? helpers.string.normalize( value ) : value.toLowerCase() ); }; // normalize() // ### addUniWordEntity /** * * Process uni-word entity and adds it to `uniWordEntities`. * * @method NER#addUniWordEntity * @param {string[]} words — from entity's text. * @param {object} entity — to be added. * @return {undefined} nothing! * @private */ var addUniWordEntity = function ( words, entity ) { const firstWord = words[ 0 ]; let wordCounts; // Latest value overrides previous value, if any. if ( uniWordEntities[ firstWord ] ) wordCounts = uniWordEntities[ firstWord ].wordCounts; uniWordEntities[ firstWord ] = cloneEntity( entity ); if ( wordCounts ) uniWordEntities[ firstWord ].wordCounts = wordCounts; }; // addUniWordEntity() // ### addUniWordEntity /** * * Process multi-word entity and adds it to `multiWordEntities`. * * @method NER#addMultiWordEntity * @param {string} text — property of entity. * @param {string[]} words — from entity's text. * @param {object} entity — to be added. * @return {undefined} nothing! * @private */ var addMultiWordEntity = function ( text, words, entity ) { const firstWord = words[ 0 ]; uniWordEntities[ firstWord ] = uniWordEntities[ firstWord ] || Object.create( null ); uniWordEntities[ firstWord ].wordCounts = uniWordEntities[ firstWord ].wordCounts || []; if ( uniWordEntities[ firstWord ].wordCounts.indexOf( words.length ) === -1 ) uniWordEntities[ firstWord ].wordCounts.push( words.length ); multiWordEntities[ words.join( ' ' ) ] = cloneEntity( entity ); // The expression is a simple arithmatic formulation to detect acronyms. if ( words.length === ( ( text.length + 1 ) / 2 ) ) addUniWordEntity( [ words.join( '' ) ], entity ); }; // addMultiWordEntity() // ### learn /** * * Learns the entities that must be detected via `recognize()/predict()` API * calls in a sentence that has been already tokenized either using * [wink-tokenizer](https://www.npmjs.com/package/wink-tokenizer) or follows * it's token format. * * It can be used to learn or update learnings incrementally; but it can not be * used to unlearn or delete one or more entities. * * If duplicate entity definitions are enountered then all the entries except * the **last one** are ignored. * * Acronyms must be added with space between each character; for example USA * should be added as `'u s a'` — this ensure correct detection of * `U S A` or `U. S. A.` or `U.S.A.` as `USA` \[Refer to the example below\]. * * @method NER#learn * @param {object[]} entities — where each element defines an entity via * two mandatory properties viz. `text` and `entityType` as described later. * Note if an element is *not an object* or *does not contain the mandatory * properties,* it is ignored. * * In addition to these two properties, you may optionally define two more * properties viz. `uid` and `value`, as described in the table below. * * <b>Note:</b> Apart from the above mentioned properties, you may also define additional properties . * Such properties, along with their values, will be copied to the output token as-is for consumption * by any down stream code in the NLP pipe. An example use-case is pos tagging. * You can define **pos** property in an entity defition as * `{ text: 'manchester united', entityType: 'club', pos: 'NNP' }`. * The [wink-pos-tagger](https://www.npmjs.com/package/wink-pos-tagger) will * automatically use the `pos` property (if available) to ensure correct * tagging in your context by overriding its algorithm. * * @param {string} entities[].text that must be detected as entity and may * consist of more than one word; for example, **`India`** or **`United Kindom.`** * @param {string} entities[].entityType type of the entity; for example * **`country`** * @param {string} [entities[].uid=undefined] unique id for the entity; example * usecase of `uid` is using it to access more properties of the entity from a * database. If it is `undefined` then it is automatically generated by joining * the key words of the detected entity by underscore (_). For example, `'india'` * or `'united_kingdom'.` * @param {string} [entities[].value=undefined] that is assigned to the value * property of the token; if `undefined` then it is equal to the **value** * of the token in case of uni-word entities; for multi-word entities, it is * generated automatically by joining the key words of the entries by space * character. For example, `'india'` or `'united kingdom'.` * @return {number} of actual entities learned. * @example * var trainingData = [ * { text: 'manchester united', entityType: 'club', uid: 'manu' }, * { text: 'manchester', entityType: 'city' }, * { text: 'U K', entityType: 'country', uid: 'uk' } * ]; * myNER.learn( trainingData ); * // -> 3 */ var learn = function ( entities ) { // Refer to comments for variables `uniWordEntities` & `multiWordEntities` // declarations in the beginning. let length = 0; for ( let i = 0, imax = entities.length; i < imax; i += 1 ) { const entity = entities[ i ]; // Normalize after removing extra white spaces; required for acronyms processing. const text = normalize( ( entity.text || '' ).trim().replace( /\s+/, ' ' ) ); // Add if `text` and `entityType` are defined. if ( text && entity.entityType ) { const words = text.split( /\s+/ ); length += 1; if ( words.length === 1 ) { addUniWordEntity( words, entity ); } else { addMultiWordEntity( text, words, entity ); } } } // Ensure longest sequence is highlighted first. for ( var key in uniWordEntities ) { if ( uniWordEntities[ key ].wordCounts ) uniWordEntities[ key ].wordCounts.sort( helpers.array.descending ); } return length; }; // learn() // ### isIgnorable /** * * Tests if the input `value` and `tag` can be ignored. * * @method NER#isIgnorable * @param {string} value — to be tested. * @param {string} tag — to be tested. * @return {boolean} returns `true` if it can be ignored otherwise returns `false.` * @private */ var isIgnorable = function ( value, tag ) { return ( ( cfg.tagsToIgnore && cfg.tagsToIgnore[ tag ] ) || ( cfg.valuesToIgnore && cfg.valuesToIgnore[ value ] ) ); }; // isIgnorable() // ### getNextNTokens // Tries next `n` `tokens` for a possible entity. var getNextNTokens = function ( i, n, tokens ) { // Words that are joined to form candidate entity. var words = []; // Original sequence is captured here. var originalSeq = []; // Candidate entity var entity; // The merged token for multi-word entities. var mergedToken; // Helper variables. var tag, value; var k, kmax; for ( k = i, kmax = tokens.length; k < kmax && words.length < n; k += 1 ) { value = tokens[ k ].value; tag = tokens[ k ].tag; // If ignorable then simply follow the next iteration, after saving the // current value into the original sequence of tokens; otherwise // save it in words else just reset & break! if ( isIgnorable( value, tag ) ) { originalSeq.push( value ); } else { // Ensure `words` receive normalized value. words.push( normalize( value ) ); // Here `value` must go **as is**. originalSeq.push( value ); } } // for ( k = i... ) entity = multiWordEntities[ words.join( ' ' ) ]; if ( !entity ) { words.push( singularize( words.pop() ) ); entity = multiWordEntities[ words.join( ' ' ) ]; } if ( entity ) { // Copy the entity into the merged token. mergedToken = copyKVPs( Object.create( null ), entity ); // Save original sequence of tokens that were detected as an entity. mergedToken.originalSeq = originalSeq; // If id is missing, create id by joining `words`. mergedToken.uid = entity.uid || words.join( '_' ); // If value was not defined, default to words. mergedToken.value = mergedToken.value || words.join( ' ' ); // move the next token's index — `nti` to `k`. return { token: mergedToken, nti: k }; } // No success, do not move the next token's index — `nti`. return { nti: i }; }; // getNextNTokens() // ### lookup /** * Exports the JSON of the learnings generated by `learn()`, which may be * saved in a file that may be used later for NER purpose. * * @method NER#lookup * @private * @param {number} i — index of tokens, from where the lookup is performed. * @param {object[]} tokens — input tokens to NER. * @param {object} candidate — result of lookup in `uniWordEntities`. * @param {array} nerdts — NERed tokens. * @return {number} of the learnings. * @example * var learnings = myNER.exportJSON(); */ var lookup = function ( i, tokens, candidate, nerdts ) { var result; for ( var k = 0, kmax = candidate.wordCounts.length; k < kmax; k += 1 ) { result = getNextNTokens( i, candidate.wordCounts[ k ], tokens ); if ( result.token ) { result.token.tag = 'word'; nerdts.push( result.token ); break; } } return result.nti; }; // ### recognize /** * * Recognizes entities in the input `tokens.` Any token(s), which is recognized * as an entity, will automatically receive the properties that have been defined * for the *detected entity* using [`learn()`](#learn). If a set of tokens together * are recognized as a single entity, then they are merged in to a single * token; the merged tokens `value` property becomes the concatenation of all * the `values` from merged tokens, separated by space. * * @method NER#recognize * @param {object[]} tokens — tokenized either using * [wink-tokenizer](https://www.npmjs.com/package/wink-tokenizer) or follow * it's standards. * * @return {object[]} of updated `tokens` with entities tagged. * @example * // Use wink tokenizer. * var winkTokenizer = require( 'wink-tokenizer' ); * // Instantiate it and use tokenize() api. * var tokenize = winkTokenizer().tokenize; * var tokens = tokenize( 'Manchester United is a professional football club based in Manchester, U. K.' ) * // Detect entities. * myNER.recognize( tokens ); * // -> [ * // { entityType: 'club', uid: 'manu', originalSeq: [ 'Manchester', 'United' ], value: 'manchester united', tag: 'word' }, * // { value: 'is', tag: 'word' }, * // { value: 'a', tag: 'word' }, * // { value: 'professional', tag: 'word' }, * // { value: 'football', tag: 'word' }, * // { value: 'club', tag: 'word' }, * // { value: 'based', tag: 'word' }, * // { value: 'in', tag: 'word' }, * // { value: 'Manchester', tag: 'word', originalSeq: [ 'Manchester' ], uid: 'manchester', entityType: 'city' }, * // { value: ',', tag: 'punctuation' }, * // { entityType: 'country', uid: 'uk', originalSeq: [ 'U', '.', 'K' ], value: 'u k', tag: 'word' }, * // { value: '.', tag: 'punctuation' } * // ] */ var recognize = function ( tokens ) { // var tokens = sentence; // tokenize( sentence.toLowerCase() ); // NERed Tokens. var nerdts = []; var t; var inew; var action, candidate; for ( var i = 0, imax = tokens.length; i < imax; i += 1 ) { t = tokens[ i ]; if ( !cfg.tagsToIgnore || !cfg.tagsToIgnore[ t.tag ] ) { // Look up for the word; if not found try its base form. let value = t.value; value = normalize( value ); candidate = uniWordEntities[ value ]; if ( !candidate ) { // This ensures that right `value` is used for `id`. value = singularize( value ); candidate = uniWordEntities[ value ]; } // NE lookup may return `undefined` meaning that the current token is // neither an entity or the first word of a multi-word entity (1). // Otherwise if `wordCounts` is `undefined` means that it is an uni-word // entity (2). Finally with given `wordCounts`, if the `entityType` is // `undefined` means that it will be a multi-word entity (3); otherwise // it is an uni/multi word entity (4) — try to match the longest one first! action = ( candidate === undefined ) ? 1 : ( candidate.wordCounts === undefined ) ? 2 : ( candidate.entityType === undefined ) ? 3 : 4; switch ( action ) { // eslint-disable-line default-case case 1: // Non-entity, just push the token to `nerdts`. nerdts.push( t ); break; case 2: // The value pointed to an uni-word named entity, push it to `nerdts` t.originalSeq = [ t.value ]; t.uid = value; // If value was not defined, default to `value`. candidate.value = candidate.value || value; nerdts.push( copyKVPs( t, candidate ) ); break; case 3: // Lookup multi-word entity(s) as per the `candiadte.wordCounts`. inew = lookup( i, tokens, candidate, nerdts ); if ( i === inew ) nerdts.push( t ); else i = inew - 1; break; case 4: // Lookup for uni/multi-word entity, starting with longest one first. inew = lookup( i, tokens, candidate, nerdts ); if ( i === inew ) { t.originalSeq = [ t.value ]; t.uid = value; nerdts.push( copyKVPs( t, candidate ) ); } else i = inew - 1; break; } } else { nerdts.push( t ); } } return ( nerdts ); }; // recognize() // ### initialize /** * Initalizes the config and learning related variables. * * @method NER#initialize * @private * @return {undefined} of the learnings. */ var initialize = function ( ) { // Define default configuration. // Ignore `punctuation`; cfg.tagsToIgnore = Object.create( null ); cfg.tagsToIgnore.punctuation = true; // Leave values to ignore `undefined`; cfg.valuesToIgnore = Object.create( null ); // And ignore diacritics by default. cfg.ignoreDiacritics = true; // Initialize learnings uniWordEntities = Object.create( null ); multiWordEntities = Object.create( null ); }; // initialize() // ### reset /** * * Resets the named entity recognizer by re-initializing all the learnings and * by setting the configuration to default. * * @method NER#reset * @return {boolean} always true. * @example * myNER.reset( ); * // -> true */ var reset = function ( ) { initialize(); return true; }; // reset() // ### exportJSON /** * * Exports the JSON of the learnings generated by `learn()`, which may be * saved in a file that may be used later for NER purpose. * * @method NER#exportJSON * @return {json} of the learnings. * @example * var learnings = myNER.exportJSON(); */ var exportJSON = function ( ) { return ( JSON.stringify( [ cfg, uniWordEntities, multiWordEntities, // For future expansion but the import will have to have intelligence to // set the default values and still ensure nothing breaks! Hopefully!! {}, [], [] ] ) ); }; // exportJSON() // ### importJSON /** * * Imports the ner learnings from an already exported ner learnings via the * [`exportJSON()`](#exportjson). * * @method NER#importJSON * @param {json} json — containg an earlier exported learnings in JSON format. * @return {boolean} always `true`. * @throws {error} if invalid JSON is encountered. * @example * var myNER = ner(); * // Assuming that `json` has valid learnings. * myNER.importJSON( json ); */ var importJSON = function ( json ) { if ( !json ) { throw Error( 'wink-ner: undefined or null JSON encountered, import failed!' ); } // Validate json format var isOK = [ helpers.object.isObject, helpers.object.isObject, helpers.object.isObject, helpers.object.isObject, helpers.array.isArray, helpers.array.isArray ]; var parsedJSON; try { parsedJSON = JSON.parse( json ); } catch ( ex ) { throw Error( 'wink-ner: invalid JSON structure encountered, can not import.' ); } if ( !helpers.array.isArray( parsedJSON ) || parsedJSON.length !== isOK.length ) { throw Error( 'wink-ner: invalid JSON format encountered, can not import.' ); } for ( var i = 0; i < isOK.length; i += 1 ) { if ( !isOK[ i ]( parsedJSON[ i ] ) ) { throw Error( 'wink-ner: invalid JSON element encountered, can not import.' ); } } // All good, setup variable values. // First reset everything. reset(); // Load variable values. cfg = parsedJSON[ 0 ]; uniWordEntities = parsedJSON[ 1 ]; multiWordEntities = parsedJSON[ 2 ]; // Return success. return true; }; // importJSON() // Main Code starts here! initialize(); methods.defineConfig = defineConfig; methods.learn = learn; methods.predict = recognize; methods.recognize = recognize; methods.exportJSON = exportJSON; methods.importJSON = importJSON; methods.reset = reset; return methods; }; module.exports = ner; var x = ner(); var t = require( 'wink-tokenizer' )().tokenize; x.learn( [ { text: 'f - 16', entityType: 'plane' } ] ); x.recognize( t( '-p' ) );