// wink-perceptron
// Multi-class averaged perceptron
//
// Copyright (C) 2017-18 GRAYPE Systems Private Limited
//
// This file is part of “wink-perceptron”.
//
// 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 helpers = require( 'wink-helpers' );
var shuffleArray = helpers.array.shuffle;
/* eslint-disable guard-for-in */
// wink multi-class averaged perceptron; its implementation is inspired by the
// research titled, "Practical Structured Learning Techniques for Natural Language
// Processing" by Harold Charles Daume dated August 2006.
//
// The essential idea is to (a) avoid addition of entire vector at every iteration,
// (b) perform computations only when updates occur, and (c) completely leverage
// the sparisity (if any) present in the vector. The weight and bias adjustment
// funtions capture this idea.
// ### wink-perceptron
/**
*
* Creates an instance of {@link Perceptron}.
*
* @return {Perceptron} Object conatining set of API methods for preceptron
* training, prediction, etc.
* @example
* // Load wink perceptron.
* var perceptron = require( 'wink-perceptron' );
* // Create your instance of wink perceptron.
* var myPerceptron = perceptron();
*/
var perceptron = function () {
// Learning Variables.
// These are re-initialized in the `reset()` method.
// The weights matrix with **features** x **classes** dimensions.
var weights = Object.create( null );
// Bias for every **class**.
var biases = Object.create( null );
// Following set of five variables are used to compute averages. The average
// is computed by dividing the accumulated sums by updates.
// Sum of weights used for comuting average weight.
var sumOfWts = Object.create( null );
// Alias for above: during call to `averageBalance()` sum is divided by updates
// to turn it into average.
var avgWts = sumOfWts;
// Sum of biases used for computing average bias.
var sumOfBiases = Object.create( null );
// Alias for above: during call to `averageBalance()` sum is divided by updates
// to turn it into average.
var avgBiases = sumOfBiases;
// Captures the last moment/epoch when an update of weight for a class/feature
// combo has occurred. The moment/epoch here is nothing but the update number.
var lastWtUpdatedAt = Object.create( null );
// Captures the last iteration when an update of bias for a class occurred.
var lastBsUpdatedAt = Object.create( null );
// The number of updates.
var updates = 0;
// Number of examples seen.
var examplesSeen = 0;
// Imported flag to allow prediction without learning.
var imported = false;
// Configuration Variables and their default values.
// Maximum number of learning iterations.
var maxIterations = 9;
// True means data will be shuffled after every iteration.
var shuffleData = false;
// Features Extractor function — used to extract features from each element of
// the `data` that is passed to learn api. This ensures that shuffling occurs
// at the `data` array level and not at feature level.
var featureExtractor = null;
/**
* @classdesc Multi-class Averaged Perceptron class.
* @class Perceptron
* @hideconstructor
*/
var methods = Object.create( null );
// ### predictUsingSpecificWeights
/**
*
* Predicts the label for the input `features` using the `specificWeights` and
* `specificBiases`. For example during learning process `weights` and `biases`
* are used.
*
* If it is unable to predict then it returns a value **`unknown`**.
*
* @private
* @param {object} features object that contains **name/value** pairs for every
* feature.
* @param {object} specificWeights these are either `weights` or `avgWts`.
* @param {object} specificBiases these are either `biases` or `avgBiases`.
*
* @return {string} Predicted class label for the input `features`.
*/
var predictUsingSpecificWeights = function ( features, specificWeights, specificBiases ) {
// Scores, index by **class**.
var scores = Object.create( null );
// Helper variables for class, feature, it's value and weight.
var c, f, v, w;
// Previous class; finally contains the predicted class!
var pc = '';
// Previous value.
var pv = -Infinity;
// Compute scores for each class; will add bias later when we just loop for
// classes while finding the maximum score.
for ( f in features ) {
w = specificWeights[ f ];
v = features[ f ];
// Check if weights for that featues exist and feature value is **non-zero**.
if ( w && v ) {
for ( c in specificWeights[ f ] ) {
scores[ c ] = ( scores[ c ] || 0 ) + ( v * w[ c ] );
}
}
}
// Find the best class with the maximum score.
for ( c in scores ) {
// Add bias at this stage.
v = scores[ c ] + specificBiases[ c ];
if ( v > pv ) {
pc = c;
pv = v;
} else if ( v === pv && c > pc ) {
// Values being equal, fall back to alpha sort!
pc = c;
}
}
return ( pc || 'unknown' );
}; // predictUsingSpecificWeights()
var adjustWt = function ( f, v, c ) {
var w = ( weights[ f ][ c ] || 0 );
lastWtUpdatedAt[ f ][ c ] = lastWtUpdatedAt[ f ][ c ] || 0;
// Update sum by adding the last weight times the difference between last
// and current update counts.
sumOfWts[ f ][ c ] = ( sumOfWts[ f ][ c ] || 0 ) + ( ( updates - lastWtUpdatedAt[ f ][ c ] ) * w );
lastWtUpdatedAt[ f ][ c ] = updates;
weights[ f ][ c ] = w + v;
}; // adjustWt()
var adjustBs = function ( v, c ) {
var b = ( biases[ c ] || 0 );
// Update sum by adding the last weight times the difference between last
// and current update counts.
sumOfBiases[ c ] = ( sumOfBiases[ c ] || 0 ) + ( ( updates - lastBsUpdatedAt[ c ] ) * b );
lastBsUpdatedAt[ c ] = updates;
biases[ c ] = b + v;
}; // adjustBs()
var averageBalance = function () {
var c, f;
var b, w;
// Follows the logic similar to `adjustWt()` & `adjustBs()`.
// Process weights.
for ( f in weights ) {
for ( c in weights[ f ] ) {
w = weights[ f ][ c ];
sumOfWts[ f ][ c ] = ( sumOfWts[ f ][ c ] || 0 ) + ( ( updates - lastWtUpdatedAt[ f ][ c ] ) * w );
// Compute average of weights.
avgWts[ f ][ c ] = +( sumOfWts[ f ][ c ] / updates ).toFixed( 3 );
}
}
// Process biases.
for ( c in biases ) {
b = biases[ c ];
sumOfBiases[ c ] = ( sumOfBiases[ c ] || 0 ) + ( ( updates - lastBsUpdatedAt[ c ] ) * b );
// Compute average of biases.
avgBiases[ c ] = +( sumOfBiases[ c ] / updates ).toFixed( 3 );
}
}; // averageBalance()
var adjustWeights = function ( data, guess ) {
var features = data[ 0 ];
var truth = data[ 1 ].label;
// Helper variable for feature.
var f, v;
// Next update means increment updates.
updates += 1;
for ( f in features ) {
if ( !weights[ f ] ) weights[ f ] = Object.create( null );
if ( !lastWtUpdatedAt[ f ] ) lastWtUpdatedAt[ f ] = Object.create( null );
if ( !sumOfWts[ f ] ) sumOfWts[ f ] = Object.create( null );
v = features[ f ];
adjustWt( f, v, truth );
if ( guess !== 'unknown' ) adjustWt( f, -v, guess );
}
adjustBs( +1, truth );
if ( guess !== 'unknown' ) adjustBs( -1, guess );
}; // adjustWeights()
var shuffle = function ( arr ) {
if ( shuffleData ) shuffleArray( arr );
};
var learnFromData = function ( data ) {
// Prediction.
var guess;
// Helper variables for loops.
var j, k;
// Starting from **1** ensures that we iterate **maxIterations** times.
for ( j = 0; j < maxIterations; j += 1 ) {
for ( k = 0; k < data.length; k += 1 ) {
guess = predictUsingSpecificWeights( data[ k ][ 0 ], weights, biases );
if ( guess !== data[ k ][ 1 ].label ) adjustWeights( data[ k ], guess );
} // for data.length
// Random shuffle of the data — critical for perceptron learning.
shuffle( data );
} // for maxIterations
averageBalance();
}; // learnFromData()
var learnFromExtractedFeatures = function ( data ) {
var features;
// Prediction.
var guess;
// Helper variables for loops.
var j, k, l;
// Starting from **1** ensures that we iterate **maxIterations** times.
for ( j = 0; j < maxIterations; j += 1 ) {
for ( k = 0; k < data.length; k += 1 ) {
features = featureExtractor( data[ k ] );
for ( l = 0; l < features.length; l += 1 ) {
guess = predictUsingSpecificWeights( features[ l ][ 0 ], weights, biases );
if ( guess !== features[ l ][ 1 ].label ) adjustWeights( features[ l ], guess );
} // for features.length
} // for data.length
// Random shuffle of the data — critical for perceptron learning.
shuffle( data );
} // for maxIterations
averageBalance();
}; // learnFromExtractedFeatures()
// ### learn
/**
*
* Learns from the **examples**. The hyperparameters, defined via [`defineConfig`](#defineConfig),
* control learning process.
*
* @method Perceptron#learn
* @param {array[]} examples each example is a 2-element array. The
* first element describes example's features and the second one defines
* its class label. Both of these are expressed in form of an object. The
* features object contains **name/numeric-value** pairs for every feature, whereas the
* class label contains single name/string-value pair as `{ label: <class> }`.
*
* @return {number} Number of examples passed.
* @example
* myPerceptron.learn( examples );
* @throws Error if all `examples` belong to only **one** class OR if attempted
* after [`importJSON()`](#importJSON).
*/
var learn = function ( examples ) {
if ( imported ) {
throw Error( 'wink-perceptron: learnings already imported.' );
}
if ( typeof featureExtractor === 'function' ) {
learnFromExtractedFeatures( examples );
} else {
learnFromData( examples );
}
if ( ( Object.keys( sumOfBiases ) ).length < 2 ) {
throw Error( 'wink-perceptron: there must be at least 2 classes in examples.' );
}
examplesSeen = examples.length;
return ( examplesSeen );
}; // learn()
// ### predict
/**
*
* Predicts the label for the input `features`. If it is unable to predict then
* it returns a value **`unknown`**.
*
* @method Perceptron#predict
* @param {object} features object that contains **name/value** pairs for every
* feature.
*
* @return {string} Predicted class label for the input `features`.
* @example
* myPerceptron.predict( features );
* @throws Error if prediction is attempted without [learning](#learn) or [import](#importJSON).
*/
var predict = function ( features ) {
if ( !imported && examplesSeen === 0 ) {
throw Error( 'wink-perceptron: prediction is not possible without learning!' );
}
// Use averaged weights.
return predictUsingSpecificWeights( features, avgWts, avgBiases );
}; // predict()
// ### defineConfig
/**
*
* Defines the hyperparameters for perceptron.
*
* @method Perceptron#defineConfig
* @param {object} config table below details the properties of `config` object.
*
* *An empty config object restores the default configuration.*
*
* @param {boolean} [config.shuffleData=false] determines whether or not the
* training examples should be randomly shuffled after each iteration a.k.a epoch.
* @param {number} [config.maxIterations=9] number of passes that must be made
* over the examples in order to complete the learning.
* @param {function} [config.featureExtractor=null] extracts feature(s) along with the corresponding class label(s)
* from example prior to each iteration. This is useful when raw examples need to be
* passed to [`learn()`](#learn) instead of features & labels. If it extracts >1 features
* then each of the extracted feature/label pair is processed sequentially during learning.
* Note `shuffleData` value will only control the shuffling of input examples and
* not of the extracted features with this function.
* @return {object} A copy of configuration defined.
* @example
* // Enable random shuffling of examples!
* myPerceptron.defineConfig( { shuffleData: true } );
* // -> { shuffleData: true, maxIterations: 9, featureExtractor: null }
*/
var defineConfig = function ( config ) {
if ( !helpers.object.isObject( config ) ) {
throw Error( 'wink-perceptron: config must be an object, instead found: ' + ( typeof config ) );
}
// Convert 'truthy -> true' or `falsy -> false`. This also implies that
// default is **`false`**.
shuffleData = !!config.shuffleData;
// Default # of maximum iteration is **6**.
maxIterations = config.maxIterations || maxIterations;
if ( maxIterations < 1 ) {
throw Error( 'wink-perceptron: maxIterations should be >1' );
}
// Ordered Set Of Features Extractor function; default is none!
featureExtractor = ( config.featureExtractor === undefined ) ? featureExtractor : config.featureExtractor;
if ( ( featureExtractor !== null ) && ( typeof featureExtractor !== 'function' ) ) {
throw Error( 'wink-perceptron: featureExtractor must be a function, instead found: ' + ( typeof featureExtractor ) );
}
return ( { shuffleData: shuffleData, maxIterations: maxIterations, featureExtractor: featureExtractor } );
}; // defineConfig()
// ### reset
/**
* It completely resets the perceptron by re-initializing all the learning
* related variables but does not touch the existing configuration.
*
* *Since it does not reset the existing configuration, user must define it
* again prior to learning if required.*
*
* @method Perceptron#reset
* @return {boolean} Always true.
* @example
* myPerceptron.reset();
* // -> true
*/
var reset = function () {
// Initialize learning variables.
weights = Object.create( null );
biases = Object.create( null );
sumOfWts = Object.create( null );
sumOfBiases = Object.create( null );
lastWtUpdatedAt = Object.create( null );
lastBsUpdatedAt = Object.create( null );
updates = 0;
examplesSeen = 0;
imported = false;
// Setup aliases.
avgWts = sumOfWts;
avgBiases = sumOfBiases;
// Always true!
return true;
}; // reset()
// ### exportJSON
/**
* Exports the learning as a JSON, which may be saved as a text file for
* later use via [`importJSON()`](#importJSON).
*
* @method Perceptron#exportJSON
* @return {string} Learning in JSON format.
* @example
* // Assuming that learn() method has been already succesful.
* myPerceptron.exportJSON();
* // -> JSON string.
* @throws Error if export is attempted without [learning](#learn).
*/
var exportJSON = function ( ) {
if ( examplesSeen === 0 ) {
throw Error( 'wink-perceptron: nothing to export, learning is a prerequisite!' );
}
return (
JSON.stringify( [
avgWts,
avgBiases,
// For future expansion...
{},
[]
] )
);
}; // exportJSON()
// ### importJSON
/**
* Imports an existing JSON learning for prediction purpose **only**; it cannot
* be used for further [learning](#learn).
*
* @method Perceptron#importJSON
* @param {JSON} json containing learnings in as exported by [`exportJSON`](#exportjson).
* @return {boolean} Always true.
* @example
* // Assuming that `json` already has a valid JSON string.
* myPerceptron.importJSON( json );
* @throws Error if `json` is invalid.
*/
var importJSON = function ( json ) {
var parsedJSON;
if ( !json ) {
throw Error( 'wink-perceptron: undefined or null JSON encountered, import failed!' );
}
// Validate json format
var isOK = [
helpers.object.isObject,
helpers.object.isObject,
helpers.object.isObject,
helpers.array.isArray
];
try {
parsedJSON = JSON.parse( json );
} catch ( ex ) {
throw Error( 'wink-perceptron: JSON parsing error during import:\n\t' + ex.message );
}
if ( !helpers.array.isArray( parsedJSON ) || parsedJSON.length !== isOK.length ) {
throw Error( 'wink-perceptron: invalid JSON encountered, can not import.' );
}
for ( var i = 0; i < isOK.length; i += 1 ) {
if ( !isOK[ i ]( parsedJSON[ i ] ) ) {
throw Error( 'wink-perceptron: invalid JSON encountered, can not import.' );
}
}
// All good, setup variable values.
// First reset everything.
reset();
// Load variable values.
avgWts = sumOfWts = parsedJSON[ 0 ];
avgBiases = sumOfBiases = parsedJSON[ 1 ];
// Return success.
imported = true;
return true;
}; // importJSON()
methods.defineConfig = defineConfig;
methods.learn = learn;
methods.predict = predict;
methods.reset = reset;
methods.exportJSON = exportJSON;
methods.importJSON = importJSON;
// methods.show = function () { console.log(sumOfWts); console.log(sumOfBiases); console.log( updates ); }; // eslint-disable-line
return ( methods );
}; // perceptron()
module.exports = perceptron;