From 63784cb23202684fd9680941e97ade8fcd788f20 Mon Sep 17 00:00:00 2001 From: Abhist17 Date: Fri, 18 Sep 2026 12:18:06 +0530 Subject: [PATCH 1/3] fix: correct modified Huber loss gradient in `ml/incr/binary-classification` The `else` branch of `_modifiedHuberLoss` was missing a factor of `2` and was applied unconditionally for `d = y*f(x) < 1`, including the `d >= 1` region where the loss (and its gradient) is exactly zero. Per the modified Huber loss definition (Wikipedia, "Variant for classification"; scikit-learn's `_sgd_fast.pyx.tp` `ModifiedHuber`), the gradient of the loss w.r.t. the decision value `p = y*f(x)` is: - `p >= 1`: `0` - `-1 <= p < 1`: `-2*y*(1-p)` - `p < -1`: `-4*y` The weight update is `w += eta * (-dloss/dp) * x`, so the middle region needs `2*eta*(y - p*y)`, not `eta*(y - p*y)`, and the `p >= 1` region needs no update at all. Fixes #13111 --- .../incr/binary-classification/lib/model.js | 4 +- .../binary-classification/test/test.model.js | 80 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/lib/node_modules/@stdlib/ml/incr/binary-classification/lib/model.js b/lib/node_modules/@stdlib/ml/incr/binary-classification/lib/model.js index 69f543905fd3..84de66a3f841 100644 --- a/lib/node_modules/@stdlib/ml/incr/binary-classification/lib/model.js +++ b/lib/node_modules/@stdlib/ml/incr/binary-classification/lib/model.js @@ -341,8 +341,8 @@ setReadOnly( Model.prototype, '_modifiedHuberLoss', function modifiedHuber( x, y d = y * this._dot( x.data, x.strides[ 0 ], x.offset ); if ( d < -1.0 ) { this._add( x, 4.0*eta*y ); - } else { - this._add( x, eta*( y-(d*y) ) ); + } else if ( d < 1.0 ) { + this._add( x, 2.0*eta*( y-(d*y) ) ); } return this; }); diff --git a/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js b/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js index 29a2bfcdb5be..ba8528b1ba1e 100644 --- a/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js +++ b/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js @@ -21,9 +21,30 @@ // MODULES // var tape = require( 'tape' ); +var array = require( '@stdlib/ndarray/array' ); +var Float64Array = require( '@stdlib/array/float64' ); var Model = require( './../lib/model.js' ); +// FUNCTIONS // + +/** +* Returns a model configured to use the modified Huber loss function with a constant learning rate and no regularization. +* +* @private +* @param {number} eta - learning rate +* @returns {Model} model instance +*/ +function createModel( eta ) { + return new Model( 1, { + 'lambda': 0.0, + 'learningRate': [ 'constant', eta ], + 'loss': 'modifiedHuber', + 'intercept': false + }); +} + + // TESTS // tape( 'main export is a function', function test( t ) { @@ -32,4 +53,61 @@ tape( 'main export is a function', function test( t ) { t.end(); }); -// TODO: add tests +tape( 'the modified Huber loss update includes a `2` factor when `-1 <= y*f(x) < 1` (see stdlib-js/stdlib#13111)', function test( t ) { + var expected; + var model; + var eta; + var x; + + eta = 0.1; + model = createModel( eta ); + + // Force `d = y*f(x) = 0.3`, landing in the quadratically smoothed region: + model._weights[ 0 ] = 0.3; // eslint-disable-line no-underscore-dangle + x = array( new Float64Array( [ 1.0 ] ) ); + + model.update( x, 1 ); + + // Expected update: w += 2*eta*( y - d*y ) = 0.3 + 2*0.1*( 1 - 0.3*1 ): + expected = 0.3 + ( 2.0*eta*( 1.0-(0.3*1.0) ) ); + t.strictEqual( model._weights[ 0 ], expected, 'includes the `2` factor in the update' ); // eslint-disable-line no-underscore-dangle + t.end(); +}); + +tape( 'the modified Huber loss does not update the model weight vector when `y*f(x) >= 1`', function test( t ) { + var model; + var eta; + var x; + + eta = 0.1; + model = createModel( eta ); + + // Force `d = y*f(x) = 2.0`, past the margin, where the gradient is zero: + model._weights[ 0 ] = 2.0; // eslint-disable-line no-underscore-dangle + x = array( new Float64Array( [ 1.0 ] ) ); + + model.update( x, 1 ); + + t.strictEqual( model._weights[ 0 ], 2.0, 'leaves the model weight vector unchanged' ); // eslint-disable-line no-underscore-dangle + t.end(); +}); + +tape( 'the modified Huber loss update matches the existing `-4*eta*y` branch when `y*f(x) < -1`', function test( t ) { + var expected; + var model; + var eta; + var x; + + eta = 0.1; + model = createModel( eta ); + + // Force `d = y*f(x) = -2.0`: + model._weights[ 0 ] = -2.0; // eslint-disable-line no-underscore-dangle + x = array( new Float64Array( [ 1.0 ] ) ); + + model.update( x, 1 ); + + expected = -2.0 + ( 4.0*eta*1.0 ); + t.strictEqual( model._weights[ 0 ], expected, 'applies the unchanged `d < -1` branch' ); // eslint-disable-line no-underscore-dangle + t.end(); +}); From 1f237971a55e30b8bdc99cef628e76254e9248a8 Mon Sep 17 00:00:00 2001 From: Abhist17 Date: Tue, 29 Sep 2026 21:49:33 +0530 Subject: [PATCH 2/3] test: consolidate the modified Huber loss tests Address review feedback: create each model inside the test case and cover the three branches of the loss in a single test. --- .../binary-classification/test/test.model.js | 77 +++++-------------- 1 file changed, 19 insertions(+), 58 deletions(-) diff --git a/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js b/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js index ba8528b1ba1e..9d3b87ed3f73 100644 --- a/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js +++ b/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js @@ -26,25 +26,6 @@ var Float64Array = require( '@stdlib/array/float64' ); var Model = require( './../lib/model.js' ); -// FUNCTIONS // - -/** -* Returns a model configured to use the modified Huber loss function with a constant learning rate and no regularization. -* -* @private -* @param {number} eta - learning rate -* @returns {Model} model instance -*/ -function createModel( eta ) { - return new Model( 1, { - 'lambda': 0.0, - 'learningRate': [ 'constant', eta ], - 'loss': 'modifiedHuber', - 'intercept': false - }); -} - - // TESTS // tape( 'main export is a function', function test( t ) { @@ -53,61 +34,41 @@ tape( 'main export is a function', function test( t ) { t.end(); }); -tape( 'the modified Huber loss update includes a `2` factor when `-1 <= y*f(x) < 1` (see stdlib-js/stdlib#13111)', function test( t ) { +tape( 'the function supports modified Huber loss', function test( t ) { var expected; var model; + var opts; var eta; var x; eta = 0.1; - model = createModel( eta ); - - // Force `d = y*f(x) = 0.3`, landing in the quadratically smoothed region: - model._weights[ 0 ] = 0.3; // eslint-disable-line no-underscore-dangle + opts = { + 'lambda': 0.0, + 'learningRate': [ 'constant', eta ], + 'loss': 'modifiedHuber', + 'intercept': false + }; x = array( new Float64Array( [ 1.0 ] ) ); + // Case 1: `-1 <= y*f(x) < 1` (quadratically smoothed region), where the update is `2*eta*( y - d*y )` (see stdlib-js/stdlib#13111): + model = new Model( 1, opts ); + model._weights[ 0 ] = 0.3; // eslint-disable-line no-underscore-dangle model.update( x, 1 ); - - // Expected update: w += 2*eta*( y - d*y ) = 0.3 + 2*0.1*( 1 - 0.3*1 ): expected = 0.3 + ( 2.0*eta*( 1.0-(0.3*1.0) ) ); - t.strictEqual( model._weights[ 0 ], expected, 'includes the `2` factor in the update' ); // eslint-disable-line no-underscore-dangle - t.end(); -}); - -tape( 'the modified Huber loss does not update the model weight vector when `y*f(x) >= 1`', function test( t ) { - var model; - var eta; - var x; + t.strictEqual( model._weights[ 0 ], expected, 'returns expected value' ); // eslint-disable-line no-underscore-dangle - eta = 0.1; - model = createModel( eta ); - - // Force `d = y*f(x) = 2.0`, past the margin, where the gradient is zero: + // Case 2: `y*f(x) >= 1`, where the gradient is zero and the weights are unchanged: + model = new Model( 1, opts ); model._weights[ 0 ] = 2.0; // eslint-disable-line no-underscore-dangle - x = array( new Float64Array( [ 1.0 ] ) ); - model.update( x, 1 ); + t.strictEqual( model._weights[ 0 ], 2.0, 'returns expected value' ); // eslint-disable-line no-underscore-dangle - t.strictEqual( model._weights[ 0 ], 2.0, 'leaves the model weight vector unchanged' ); // eslint-disable-line no-underscore-dangle - t.end(); -}); - -tape( 'the modified Huber loss update matches the existing `-4*eta*y` branch when `y*f(x) < -1`', function test( t ) { - var expected; - var model; - var eta; - var x; - - eta = 0.1; - model = createModel( eta ); - - // Force `d = y*f(x) = -2.0`: + // Case 3: `y*f(x) < -1`, where the update is `4*eta*y`: + model = new Model( 1, opts ); model._weights[ 0 ] = -2.0; // eslint-disable-line no-underscore-dangle - x = array( new Float64Array( [ 1.0 ] ) ); - model.update( x, 1 ); - expected = -2.0 + ( 4.0*eta*1.0 ); - t.strictEqual( model._weights[ 0 ], expected, 'applies the unchanged `d < -1` branch' ); // eslint-disable-line no-underscore-dangle + t.strictEqual( model._weights[ 0 ], expected, 'returns expected value' ); // eslint-disable-line no-underscore-dangle + t.end(); }); From 61f5267e439e7a29a80d531de6f47ede9efe2d69 Mon Sep 17 00:00:00 2001 From: Abhist17 Date: Fri, 2 Oct 2026 04:37:16 +0530 Subject: [PATCH 3/3] test: simplify case comments Co-authored-by: Nakul Krishnakumar --- .../@stdlib/ml/incr/binary-classification/test/test.model.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js b/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js index 9d3b87ed3f73..2b336a846e8f 100644 --- a/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js +++ b/lib/node_modules/@stdlib/ml/incr/binary-classification/test/test.model.js @@ -50,7 +50,7 @@ tape( 'the function supports modified Huber loss', function test( t ) { }; x = array( new Float64Array( [ 1.0 ] ) ); - // Case 1: `-1 <= y*f(x) < 1` (quadratically smoothed region), where the update is `2*eta*( y - d*y )` (see stdlib-js/stdlib#13111): + // Case 1: `-1 <= y*f(x) < 1` (quadratically smoothed region): model = new Model( 1, opts ); model._weights[ 0 ] = 0.3; // eslint-disable-line no-underscore-dangle model.update( x, 1 ); @@ -63,7 +63,7 @@ tape( 'the function supports modified Huber loss', function test( t ) { model.update( x, 1 ); t.strictEqual( model._weights[ 0 ], 2.0, 'returns expected value' ); // eslint-disable-line no-underscore-dangle - // Case 3: `y*f(x) < -1`, where the update is `4*eta*y`: + // Case 3: `y*f(x) < -1`: model = new Model( 1, opts ); model._weights[ 0 ] = -2.0; // eslint-disable-line no-underscore-dangle model.update( x, 1 );