UNPKG

@jsmlt/jsmlt

Version:

JavaScript Machine Learning

564 lines (453 loc) 24.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports["default"] = exports.BinarySVM = void 0; var _base = require("../base"); var _linear = _interopRequireDefault(require("../../kernel/linear")); var Arrays = _interopRequireWildcard(require("../../arrays")); var Random = _interopRequireWildcard(require("../../random")); function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function _getRequireWildcardCache() { return cache; }; return cache; } function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; if (obj != null) { var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } } newObj["default"] = obj; if (cache) { cache.set(obj, newObj); } return newObj; } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } function _iterableToArrayLimit(arr, i) { if (!(Symbol.iterator in Object(arr) || Object.prototype.toString.call(arr) === "[object Arguments]")) { return; } var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(source, true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(source).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); } function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } /** * SVM learner for binary classification problem. */ var BinarySVM = /*#__PURE__*/ function (_Classifier) { _inherits(BinarySVM, _Classifier); /** * Constructor. Initialize class members and store user-defined options. * * @param {Object} [optionsUser] - User-defined options for SVM * @param {number} [optionsUser.C = 100] - Regularization (i.e. penalty for slack variables) * @param {Object} [optionsUser.kernel = null] - Kernel. Defaults to the linear kernel * @param {number} [optionsUser.convergenceNumPasses = 20] - Number of passes without alphas * changing to treat the algorithm as converged * @param {number} [optionsUser.numericalTolerance = 1e-6] - Numerical tolerance for a * value in the to be equal to another SMO algorithm to be equal to another value * @param {boolean} [optionsUser.useKernelCache = true] - Whether to cache calculated kernel * values for training sample pairs. Enabling this option (which is the default) generally * improves the performance in terms of speed at the cost of memory */ function BinarySVM() { var _this; var optionsUser = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; _classCallCheck(this, BinarySVM); _this = _possibleConstructorReturn(this, _getPrototypeOf(BinarySVM).call(this)); // Parse options var optionsDefault = { C: 100.0, kernel: null, convergenceNumPasses: 20, numericalTolerance: 1e-6, useKernelCache: true }; var options = _objectSpread({}, optionsDefault, {}, optionsUser); // Set options _this.C = options.C; _this.kernel = options.kernel === null ? new _linear["default"]() : options.kernel; _this.convergenceNumPasses = options.convergenceNumPasses; _this.numericalTolerance = options.numericalTolerance; _this.useKernelCache = options.useKernelCache; // Set properties _this.isTraining = false; return _this; } /** * Get the signed value of the class index. Returns -1 for class index 0, 1 for class index 1. * * @param {number} classIndex - Class index * @return {number} Sign corresponding to class index */ _createClass(BinarySVM, [{ key: "getClassIndexSign", value: function getClassIndexSign(classIndex) { return classIndex * 2 - 1; } /** * Get the class index corresponding to a sign. * * @param {number} sign - Sign * @return {number} Class index corresponding to sign */ }, { key: "getSignClassIndex", value: function getSignClassIndex(sign) { return (sign + 1) / 2; } /** * @see {@link Classifier#train} */ }, { key: "train", value: function train(X, y) { var _this2 = this; // Mark that the SVM is currently in the training procedure this.isTraining = true; // Number of training samples var numSamples = X.length; // Alphas (Lagrange multipliers) this.alphas = Arrays.zeros(numSamples); // Bias term this.b = 0.0; // Kernel cache this.kernelCache = Arrays.full([numSamples, numSamples], 0.0); this.kernelCacheStatus = Arrays.full([numSamples, numSamples], false); // Number of passes of the algorithm without any alphas changing var numPasses = 0; // Shorthand notation for features and labels this.training = { X: X, y: y }; var ySigns = y.map(function (x) { return _this2.getClassIndexSign(x); }); while (numPasses < this.convergenceNumPasses) { var alphasChanged = 0; // Loop over all training samples for (var i = 0; i < numSamples; i += 1) { // Calculate offset to the 1-margin of sample i var ei = this.sampleMargin(i) - ySigns[i]; // Check whether the KKT constraints were violated if (ySigns[i] * ei < -this.numericalTolerance && this.alphas[i] < this.C || ySigns[i] * ei > this.numericalTolerance && this.alphas[i] > 0) { /* Now, we need to update \alpha_i as it violates the KKT constraints */ // Thus, we pick a random \alpha_j such that j does not equal i var j = Random.randint(0, numSamples - 1); if (j >= i) j += 1; // Calculate offset to the 1-margin of sample j var ej = this.sampleMargin(j) - ySigns[j]; // Calculate lower and upper bounds for \alpha_j var _this$calculateAlphaB = this.calculateAlphaBounds(i, j), _this$calculateAlphaB2 = _slicedToArray(_this$calculateAlphaB, 2), boundL = _this$calculateAlphaB2[0], boundH = _this$calculateAlphaB2[1]; if (Math.abs(boundH - boundL) < this.numericalTolerance) { // Difference between bounds is practically zero, so there's not much to optimize. // Continue to next sample. continue; } // Calculate second derivative of cost function from Lagrange dual problem. Note // that a_i = (g - a_j * y_j) / y_i, where g is the negative sum of all a_k * y_k where // k is not equal to i or j var Kij = this.applyKernel(i, j); var Kii = this.applyKernel(i, i); var Kjj = this.applyKernel(j, j); var eta = 2 * Kij - Kii - Kjj; if (eta >= 0) { continue; } // Compute new \alpha_j var oldAlphaJ = this.alphas[j]; var newAlphaJ = oldAlphaJ - ySigns[j] * (ei - ej) / eta; newAlphaJ = Math.min(newAlphaJ, boundH); newAlphaJ = Math.max(newAlphaJ, boundL); // Don't update if the difference is too small if (Math.abs(newAlphaJ - oldAlphaJ) < this.numericalTolerance) { continue; } // Compute new \alpha_i var oldAlphaI = this.alphas[i]; var newAlphaI = oldAlphaI + ySigns[i] * ySigns[j] * (oldAlphaJ - newAlphaJ); // Update \alpha_j and \alpha_i this.alphas[j] = newAlphaJ; this.alphas[i] = newAlphaI; // Update the bias term, interpolating between the bias terms for \alpha_i and \alpha_j var b1 = this.b - ei - ySigns[i] * (newAlphaI - oldAlphaI) * Kii - ySigns[j] * (newAlphaJ - oldAlphaJ) * Kij; var b2 = this.b - ej - ySigns[i] * (newAlphaI - oldAlphaI) * Kij - ySigns[j] * (newAlphaJ - oldAlphaJ) * Kjj; if (newAlphaJ > 0 && newAlphaJ < this.C) { this.b = b2; } else if (newAlphaI > 0 && newAlphaI < this.C) { this.b = b1; } else { this.b = (b1 + b2) / 2; } alphasChanged += 1; } } if (alphasChanged === 0) { numPasses += 1; } else { numPasses = 0; } } // Store indices of support vectors (where alpha > 0, or, in this case, where alpha is greater // than some numerical tolerance) this.supportVectors = Arrays.argFilter(this.alphas, function (x) { return x > 1e-3; }); // Mark that training has completed this.isTraining = false; } /** * Calculate the margin (distance to the decision boundary) for a single sample. * * @param {Array.<number>|number} sample - Sample features array or training sample index * @return {number} Distance to decision boundary */ }, { key: "sampleMargin", value: function sampleMargin(sample) { var rval = this.b; if (this.isTraining) { // If we're in the training phase, we have to loop over all elements for (var i = 0; i < this.training.X.length; i += 1) { var k = this.applyKernel(sample, i); rval += this.getClassIndexSign(this.training.y[i]) * this.alphas[i] * k; } } else { // If training is done, we only loop over the support vectors var _iteratorNormalCompletion = true; var _didIteratorError = false; var _iteratorError = undefined; try { for (var _iterator = this.supportVectors[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { var sv = _step.value; var _k = this.applyKernel(sample, this.training.X[sv]); rval += this.getClassIndexSign(this.training.y[sv]) * this.alphas[sv] * _k; } } catch (err) { _didIteratorError = true; _iteratorError = err; } finally { try { if (!_iteratorNormalCompletion && _iterator["return"] != null) { _iterator["return"](); } } finally { if (_didIteratorError) { throw _iteratorError; } } } } return rval; } /** * Apply the kernel to two data points. Accepts both feature arrays and training data point * indices for x and y. If x and y are integers, attempts to fetch the kernel result for the * corresponding training data points from cache, and computes and stores the result in cache if * it isn't found * * @param {Array.<number>|number} x - Feature vector or data point index for first data point. * Arrays are treated as feature vectors, integers as training data point indices * @param {Array.<number>|number} y - Feature vector or data point index for second data point. * Arrays are treated as feature vectors, integers as training data point indices * @return {number} Kernel result */ }, { key: "applyKernel", value: function applyKernel(x, y) { var fromCache = this.useKernelCache && typeof x === 'number' && typeof y === 'number'; if (fromCache && this.kernelCacheStatus[x][y] === true) { return this.kernelCache[x][y]; } var xf = typeof x === 'number' ? this.training.X[x] : x; var yf = typeof y === 'number' ? this.training.X[y] : y; var result = this.kernel.apply(xf, yf); if (fromCache) { this.kernelCache[x][y] = result; this.kernelCacheStatus[x][y] = true; } return result; } /** * Calculate the bounds on \alpha_j to make sure it can be clipped to the [0,C] box and that it * can be chosen to satisfy the linear equality constraint stemming from the fact that the sum of * all products y_i * a_i should equal 0. * * @param {number} i Index of \alpha_i * @param {number} j Index of \alpha_j * @return {Array.<number>} Two-dimensional array containing the lower and upper bound */ }, { key: "calculateAlphaBounds", value: function calculateAlphaBounds(i, j) { var boundL; var boundH; if (this.training.y[i] === this.training.y[j]) { // The alphas lie on a line with slope -1 boundL = this.alphas[j] - (this.C - this.alphas[i]); boundH = this.alphas[j] + this.alphas[i]; } else { // The alphas lie on a line with slope 1 boundL = this.alphas[j] - this.alphas[i]; boundH = this.alphas[j] + (this.C - this.alphas[i]); } boundL = Math.max(0, boundL); boundH = Math.min(this.C, boundH); return [boundL, boundH]; } /** * Make a prediction for a data set. * * @param {Array.<Array.<mixed>>} features - Features for each data point. Each array element * should be an array containing the features of the data point * @param {Object} [optionsUser] - Options for prediction * @param {string} [optionsUser.output = 'classLabels'] - Output for predictions. Either * "classLabels" (default, output predicted class label), "raw" or "normalized" (both output * margin (distance to decision boundary) for each sample) * @return {Array.<mixed>} Predictions. Output dependent on options.output, defaults to class * labels */ }, { key: "predict", value: function predict(features) { var _this3 = this; var optionsUser = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; // Options var optionsDefault = { output: 'classLabels' // 'classLabels', 'normalized' or 'raw' }; var options = _objectSpread({}, optionsDefault, {}, optionsUser); return features.map(function (x) { var output = _this3.sampleMargin(x); // Store prediction if (options.output === 'raw' || options.output === 'normalized') {// Raw output: do nothing } else { // Class label output output = _this3.getSignClassIndex(output > 0 ? 1 : -1); } return output; }); } /** * Retrieve the indices of the support vector samples. * * @return {Array.<number>} List of sample indices of the support vectors */ }, { key: "getSupportVectors", value: function getSupportVectors() { return this.supportVectors; } }]); return BinarySVM; }(_base.Classifier); /** * Support Vector Machine (SVM) classification model for 2 or more classes. The model is a * one-vs-all classifier and uses the {@link BinarySVM} classifier as its base model. For training * individual models, a simplified version of John Platt's * [SMO algorithm](@link https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-98-14.pdf) * is used. * * ## Support Vector Machines (SVM) * Support Vector Machines train a classifier by finding the decision boundary between the classes * that maximizes the margin between the boundary and the data points on either side of it. It is a * very intuitive approach to classification. The soft-margin SVM is a modification of this approach * where samples are allowed to be misclasiffied, but at some cost. Furthermore, SVMs can implement * the "kernel trick", where an implicit feature transformation is applied. * * This is all implemented in the SVM implementation in JSMLT. For more information about Support * Vector Machines, you can start by checking out the [Wikipedia](https://en.wikipedia.org/wiki/Support_vector_machine) * page on SVMs. * * ## Examples * **Example 1:** Training a multiclass SVM on a well-known three-class dataset, the * [Iris dataset](https://github.com/jsmlt/datasets-repository/tree/master/iris#readme). * * @example <caption>Example 1. SVM training on a multiclass classification task.</caption> * // Import JSMLT * var jsmlt = require('@jsmlt/jsmlt'); * * // Load the iris dataset. When loading is completed, process the data and run the classifier * jsmlt.Datasets.loadIris((X, y_raw) => { * // Encode the labels (which are strings) into integers * var labelencoder = new jsmlt.Preprocessing.LabelEncoder(); * var y = labelencoder.encode(y_raw); * * // Split the data into a training set and a test set * [X_train, y_train, X_test, y_test] = jsmlt.ModelSelection.trainTestSplit([X, y]); * * // Create and train classifier * var clf = new jsmlt.Supervised.SVM.SVM({ * kernel: new jsmlt.Kernel.Gaussian(1), * }); * clf.train(X_train, y_train); * * // Make predictions on test data * var predictions = clf.predict(X_test); * * // Evaluate and output the classifier's accuracy * var accuracy = jsmlt.Validation.Metrics.accuracy(predictions, y_test); * console.log(`Accuracy: ${accuracy}`); * }); * * @see {@link BinarySVM} */ exports.BinarySVM = BinarySVM; var SVM = /*#__PURE__*/ function (_OneVsAllClassifier) { _inherits(SVM, _OneVsAllClassifier); /** * Constructor. Initialize class members and store user-defined options. * * @see {@link BinarySVM#constructor} * * @param {Object} optionsUser User-defined options for SVM. Options are passed to created * BinarySVM objects. See BinarySVM.constructor() for more details * @param {Object} [optionsUser] - User-defined options for SVM * @param {number} [optionsUser.C = 100] - Regularization (i.e. penalty for slack variables) * @param {Object} [optionsUser.kernel] - Kernel. Defaults to the linear kernel * @param {number} [optionsUser.convergenceNumPasses = 10] - Number of passes without alphas * changing to treat the algorithm as converged * @param {number} [optionsUser.numericalTolerance = 1e-6] - Numerical tolerance for a * value in the to be equal to another SMO algorithm to be equal to another value * @param {boolean} [optionsUser.useKernelCache = true] - Whether to cache calculated kernel * values for training sample pairs. Enabling this option (which is the default) generally * improves the performance in terms of speed at the cost of memory */ function SVM() { var _this4; var optionsUser = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; _classCallCheck(this, SVM); _this4 = _possibleConstructorReturn(this, _getPrototypeOf(SVM).call(this)); /** * Number of errors per iteration. Only used if accuracy tracking is enabled * * @type {Array.<mixed>} */ _this4.numErrors = null; // Set options _this4.optionsUser = optionsUser; return _this4; } /** * @see {@link OneVsAll#createClassifier} */ _createClass(SVM, [{ key: "createClassifier", value: function createClassifier() { return new BinarySVM(this.optionsUser); } /** * @see {@link Estimator#train} */ }, { key: "train", value: function train(X, y) { this.training = { X: X, y: y }; this.createClassifiers(y); this.trainBatch(X, y); } /** * Retrieve the indices of the support vector samples. * * @return {Array.<number>} List of sample indices of the support vectors */ }, { key: "getSupportVectors", value: function getSupportVectors() { var _Array$prototype; var classifiersSupportVectors = this.getClassifiers().map(function (x) { return x.classifier.getSupportVectors(); }); return Arrays.unique((_Array$prototype = Array.prototype).concat.apply(_Array$prototype, _toConsumableArray(classifiersSupportVectors))); } }]); return SVM; }(_base.OneVsAllClassifier); exports["default"] = SVM;