mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-26 06:18:05 +00:00
Add R² and SMAPE error metrics with comprehensive tests and documentation
- Introduced R² (Coefficient of Determination) metric with detailed mathematical foundation, performance profile, and usage examples. - Implemented SMAPE (Symmetric Mean Absolute Percentage Error) metric, addressing asymmetry in MAPE with symmetric error calculations. - Added unit tests for SMAPE covering various scenarios including edge cases and input validation. - Enhanced Dema class to correctly handle event publishing with isNew parameter. - Updated Quantower test project to include coverage configuration for better test reporting.
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class HuberTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Huber(0));
|
||||
Assert.Throws<ArgumentException>(() => new Huber(-1));
|
||||
Assert.Throws<ArgumentException>(() => new Huber(10, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Huber(10, -1));
|
||||
|
||||
var huber = new Huber(10);
|
||||
Assert.NotNull(huber);
|
||||
|
||||
var huberWithDelta = new Huber(10, 2.0);
|
||||
Assert.NotNull(huberWithDelta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_Accessible()
|
||||
{
|
||||
var huber = new Huber(10);
|
||||
|
||||
Assert.Equal(0, huber.Last.Value);
|
||||
Assert.False(huber.IsHot);
|
||||
Assert.Contains("Huber", huber.Name, StringComparison.Ordinal);
|
||||
|
||||
huber.Update(100, 105);
|
||||
Assert.NotEqual(0, huber.Last.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
int period = 5;
|
||||
var huber = new Huber(period);
|
||||
|
||||
for (int i = 0; i < period - 1; i++)
|
||||
{
|
||||
Assert.False(huber.IsHot, $"IsHot should be false at index {i}");
|
||||
huber.Update(i * 10, i * 10 + 5);
|
||||
}
|
||||
|
||||
huber.Update((period - 1) * 10, (period - 1) * 10 + 5);
|
||||
Assert.True(huber.IsHot, "IsHot should be true after period updates");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_SmallErrors_BehavesLikeMSE()
|
||||
{
|
||||
double delta = 10.0; // Large delta so all errors are "small"
|
||||
var huber = new Huber(3, delta);
|
||||
|
||||
// Error = 0.5 (small), Huber = 0.5 * 0.5^2 = 0.125
|
||||
var res1 = huber.Update(100, 99.5);
|
||||
Assert.Equal(0.125, res1.Value, 10);
|
||||
|
||||
// Error = 1.0, Huber = 0.5 * 1^2 = 0.5, Mean = (0.125 + 0.5) / 2 = 0.3125
|
||||
var res2 = huber.Update(100, 99);
|
||||
Assert.Equal(0.3125, res2.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_LargeErrors_BehavesLikeMAE()
|
||||
{
|
||||
double delta = 1.0; // Small delta so large errors get linear treatment
|
||||
var huber = new Huber(1, delta);
|
||||
double halfDeltaSquared = 0.5 * delta * delta;
|
||||
|
||||
// Error = 10 (large), Huber = delta * |error| - 0.5 * delta^2 = 1 * 10 - 0.5 = 9.5
|
||||
var res1 = huber.Update(110, 100);
|
||||
Assert.Equal(delta * 10 - halfDeltaSquared, res1.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_TransitionPoint()
|
||||
{
|
||||
double delta = 5.0;
|
||||
var huber1 = new Huber(1, delta);
|
||||
var huber2 = new Huber(1, delta);
|
||||
|
||||
// Error exactly at delta boundary
|
||||
var atDelta = huber1.Update(105, 100);
|
||||
// 0.5 * 5^2 = 12.5
|
||||
Assert.Equal(0.5 * delta * delta, atDelta.Value, 10);
|
||||
|
||||
// Error just above delta
|
||||
var aboveDelta = huber2.Update(105.1, 100);
|
||||
// Should be very close to quadratic at transition
|
||||
// delta * 5.1 - 0.5 * delta^2 = 5 * 5.1 - 12.5 = 25.5 - 12.5 = 13
|
||||
double expected = delta * 5.1 - 0.5 * delta * delta;
|
||||
Assert.Equal(expected, aboveDelta.Value, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_PerfectPrediction_ReturnsZero()
|
||||
{
|
||||
var huber = new Huber(5);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
huber.Update(i * 10, i * 10); // Perfect prediction
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, huber.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_SymmetricForPositiveNegativeErrors()
|
||||
{
|
||||
double delta = 2.0;
|
||||
var huber1 = new Huber(1, delta);
|
||||
var huber2 = new Huber(1, delta);
|
||||
|
||||
// Positive error
|
||||
var positive = huber1.Update(105, 100);
|
||||
|
||||
// Negative error (same magnitude)
|
||||
var negative = huber2.Update(95, 100);
|
||||
|
||||
Assert.Equal(positive.Value, negative.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var huber = new Huber(10);
|
||||
|
||||
huber.Update(100, 110, isNew: true);
|
||||
double value1 = huber.Last.Value;
|
||||
|
||||
huber.Update(100, 120, isNew: true);
|
||||
double value2 = huber.Last.Value;
|
||||
|
||||
Assert.NotEqual(value1, value2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var huber = new Huber(10);
|
||||
|
||||
huber.Update(100, 110);
|
||||
huber.Update(100, 120, isNew: true);
|
||||
double beforeUpdate = huber.Last.Value;
|
||||
|
||||
huber.Update(100, 130, isNew: false);
|
||||
double afterUpdate = huber.Last.Value;
|
||||
|
||||
Assert.NotEqual(beforeUpdate, afterUpdate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var huber = new Huber(5);
|
||||
|
||||
double tenthActual = 0;
|
||||
double tenthPredicted = 0;
|
||||
|
||||
// Feed 10 updates
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
tenthActual = i * 10;
|
||||
tenthPredicted = i * 10 + 5;
|
||||
huber.Update(tenthActual, tenthPredicted);
|
||||
}
|
||||
|
||||
double stateAfterTen = huber.Last.Value;
|
||||
|
||||
// Apply 5 corrections with isNew=false
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
huber.Update(100 + i, 200 + i, isNew: false);
|
||||
}
|
||||
|
||||
// Restore to original values
|
||||
huber.Update(tenthActual, tenthPredicted, isNew: false);
|
||||
|
||||
Assert.Equal(stateAfterTen, huber.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var huber = new Huber(5);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
huber.Update(i * 10, i * 10 + 5);
|
||||
}
|
||||
|
||||
Assert.True(huber.IsHot);
|
||||
|
||||
huber.Reset();
|
||||
|
||||
Assert.False(huber.IsHot);
|
||||
Assert.Equal(0, huber.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var huber = new Huber(5);
|
||||
|
||||
huber.Update(100, 110);
|
||||
huber.Update(110, 120);
|
||||
huber.Update(120, 130);
|
||||
|
||||
var result = huber.Update(double.NaN, double.NaN);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var huber = new Huber(5);
|
||||
|
||||
huber.Update(100, 110);
|
||||
huber.Update(110, 120);
|
||||
|
||||
var result = huber.Update(double.PositiveInfinity, double.NegativeInfinity);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleNaN_ContinuesWithLastValid()
|
||||
{
|
||||
var huber = new Huber(5);
|
||||
|
||||
huber.Update(100, 110);
|
||||
huber.Update(110, 120);
|
||||
huber.Update(120, 130);
|
||||
|
||||
var r1 = huber.Update(double.NaN, double.NaN);
|
||||
var r2 = huber.Update(double.NaN, double.NaN);
|
||||
var r3 = huber.Update(double.NaN, double.NaN);
|
||||
|
||||
Assert.True(double.IsFinite(r1.Value));
|
||||
Assert.True(double.IsFinite(r2.Value));
|
||||
Assert.True(double.IsFinite(r3.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_Throws_On_Single_Input()
|
||||
{
|
||||
var huber = new Huber(10);
|
||||
Assert.Throws<NotSupportedException>(() => huber.Update(new TValue(DateTime.UtcNow, 1)));
|
||||
Assert.Throws<NotSupportedException>(() => huber.Update(new TSeries()));
|
||||
Assert.Throws<NotSupportedException>(() => huber.Prime(new double[] { 1, 2, 3 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_MatchesStreaming()
|
||||
{
|
||||
int period = 5;
|
||||
double delta = 1.345;
|
||||
int count = 100;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
|
||||
double[] actual = new double[count];
|
||||
double[] predicted = new double[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var bar = gbm.Next();
|
||||
actual[i] = bar.Close;
|
||||
predicted[i] = bar.Close * 1.05 + 2; // Offset prediction
|
||||
}
|
||||
|
||||
// Streaming
|
||||
var huber = new Huber(period, delta);
|
||||
var streamingResults = new double[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
streamingResults[i] = huber.Update(actual[i], predicted[i]).Value;
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] batchResults = new double[count];
|
||||
Huber.Batch(actual, predicted, batchResults, period, delta);
|
||||
|
||||
// Compare
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchResults[i], 9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_ValidatesInput()
|
||||
{
|
||||
double[] actual = [1, 2, 3, 4, 5];
|
||||
double[] predicted = [1, 2, 3, 4, 5];
|
||||
double[] output = new double[5];
|
||||
double[] wrongSizeOutput = new double[3];
|
||||
double[] wrongSizePredicted = new double[3];
|
||||
|
||||
// Period must be > 0
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Huber.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 0));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Huber.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), -1));
|
||||
|
||||
// Delta must be > 0
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Huber.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 3, 0));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Huber.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 3, -1));
|
||||
|
||||
// Output must be same length as source
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Huber.Batch(actual.AsSpan(), predicted.AsSpan(), wrongSizeOutput.AsSpan(), 3));
|
||||
|
||||
// Predicted must be same length as actual
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Huber.Batch(actual.AsSpan(), wrongSizePredicted.AsSpan(), output.AsSpan(), 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Works()
|
||||
{
|
||||
var actual = new TSeries();
|
||||
var predicted = new TSeries();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
actual.Add(now.AddMinutes(i), 100);
|
||||
predicted.Add(now.AddMinutes(i), 100.5); // Small constant error
|
||||
}
|
||||
|
||||
var results = Huber.Calculate(actual, predicted, 3);
|
||||
|
||||
Assert.Equal(10, results.Count);
|
||||
// Error = 0.5, Huber (small error) = 0.5 * 0.5^2 = 0.125
|
||||
Assert.Equal(0.125, results.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ValidatesMismatchedLengths()
|
||||
{
|
||||
var actual = new TSeries();
|
||||
var predicted = new TSeries();
|
||||
|
||||
for (int i = 0; i < 10; i++) actual.Add(DateTime.UtcNow, i);
|
||||
for (int i = 0; i < 5; i++) predicted.Add(DateTime.UtcNow, i);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Huber.Calculate(actual, predicted, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_HandlesNaN()
|
||||
{
|
||||
double[] actual = [100, 110, double.NaN, 130, 140];
|
||||
double[] predicted = [105, 115, 125, double.NaN, 145];
|
||||
double[] output = new double[5];
|
||||
|
||||
Huber.Batch(actual, predicted, output, 3);
|
||||
|
||||
foreach (var val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite value but got {val}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_Resync_Works()
|
||||
{
|
||||
double delta = 10.0; // Large delta for quadratic behavior
|
||||
var huber = new Huber(5, delta);
|
||||
|
||||
// Force many updates to trigger resync (ResyncInterval = 1000)
|
||||
for (int i = 0; i < 1100; i++)
|
||||
{
|
||||
huber.Update(100, 102); // Constant error of 2
|
||||
}
|
||||
|
||||
// Error = 2, Huber = 0.5 * 2^2 = 2.0
|
||||
Assert.Equal(2.0, huber.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_DefaultDelta_Is1_345()
|
||||
{
|
||||
var huber = new Huber(5);
|
||||
Assert.Contains("1.345", huber.Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Huber_DifferentDeltas_ProduceDifferentResults()
|
||||
{
|
||||
var huber1 = new Huber(5, 1.0);
|
||||
var huber2 = new Huber(5, 5.0);
|
||||
|
||||
// Large error that exceeds both deltas differently
|
||||
huber1.Update(100, 110); // Error = 10
|
||||
huber2.Update(100, 110); // Error = 10
|
||||
|
||||
// With delta=1: linear region -> 1*10 - 0.5 = 9.5
|
||||
// With delta=5: linear region -> 5*10 - 12.5 = 37.5
|
||||
Assert.NotEqual(huber1.Last.Value, huber2.Last.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Huber: Huber Loss
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Huber Loss combines the best properties of MSE and MAE. For small errors
|
||||
/// (|error| ≤ delta), it behaves like MSE (quadratic). For large errors
|
||||
/// (|error| > delta), it behaves like MAE (linear).
|
||||
///
|
||||
/// Formula:
|
||||
/// If |error| ≤ delta: L = 0.5 * error²
|
||||
/// If |error| > delta: L = delta * |error| - 0.5 * delta²
|
||||
///
|
||||
/// Key properties:
|
||||
/// - Differentiable everywhere (unlike MAE)
|
||||
/// - Robust to outliers (unlike MSE)
|
||||
/// - Delta controls the transition point
|
||||
/// - Default delta = 1.345 (for 95% efficiency with normal distribution)
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Huber : AbstractBase
|
||||
{
|
||||
private readonly double _delta;
|
||||
private readonly double _halfDeltaSquared;
|
||||
private readonly RingBuffer _buffer;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(double Sum, double LastValidActual, double LastValidPredicted, int TickCount);
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
|
||||
private const int ResyncInterval = 1000;
|
||||
|
||||
public Huber(int period, double delta = 1.345)
|
||||
{
|
||||
if (period <= 0)
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
if (delta <= 0)
|
||||
throw new ArgumentException("Delta must be greater than 0", nameof(delta));
|
||||
|
||||
_delta = delta;
|
||||
_halfDeltaSquared = 0.5 * delta * delta;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Huber({period},{delta:F3})";
|
||||
WarmupPeriod = period;
|
||||
}
|
||||
|
||||
public override bool IsHot => _buffer.IsFull;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double CalculateHuberLoss(double error)
|
||||
{
|
||||
double absError = Math.Abs(error);
|
||||
return absError <= _delta
|
||||
? 0.5 * error * error
|
||||
: _delta * absError - _halfDeltaSquared;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue actual, TValue predicted, bool isNew = true)
|
||||
{
|
||||
double actualVal = actual.Value;
|
||||
double predictedVal = predicted.Value;
|
||||
|
||||
if (!double.IsFinite(actualVal))
|
||||
actualVal = double.IsFinite(_state.LastValidActual) ? _state.LastValidActual : 0.0;
|
||||
else
|
||||
_state.LastValidActual = actualVal;
|
||||
|
||||
if (!double.IsFinite(predictedVal))
|
||||
predictedVal = double.IsFinite(_state.LastValidPredicted) ? _state.LastValidPredicted : 0.0;
|
||||
else
|
||||
_state.LastValidPredicted = predictedVal;
|
||||
|
||||
double error = actualVal - predictedVal;
|
||||
double huberLoss = CalculateHuberLoss(error);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
|
||||
double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
|
||||
_state.Sum = _state.Sum - removedValue + huberLoss;
|
||||
_buffer.Add(huberLoss);
|
||||
|
||||
_state.TickCount++;
|
||||
if (_buffer.IsFull && _state.TickCount >= ResyncInterval)
|
||||
{
|
||||
_state.TickCount = 0;
|
||||
_state.Sum = _buffer.RecalculateSum();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
|
||||
double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
|
||||
_state.Sum = _state.Sum - removedValue + huberLoss;
|
||||
_buffer.UpdateNewest(huberLoss);
|
||||
_state.Sum = _buffer.RecalculateSum();
|
||||
}
|
||||
|
||||
double result = _buffer.Count > 0 ? _state.Sum / _buffer.Count : huberLoss;
|
||||
Last = new TValue(actual.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(double actual, double predicted, bool isNew = true)
|
||||
{
|
||||
return Update(new TValue(DateTime.UtcNow, actual), new TValue(DateTime.UtcNow, predicted), isNew);
|
||||
}
|
||||
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
throw new NotSupportedException("Huber requires two inputs. Use Update(actual, predicted).");
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
throw new NotSupportedException("Huber requires two inputs. Use Calculate(actualSeries, predictedSeries, period, delta).");
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
throw new NotSupportedException("Huber requires two inputs.");
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_state = default;
|
||||
_p_state = default;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
public static TSeries Calculate(TSeries actual, TSeries predicted, int period, double delta = 1.345)
|
||||
{
|
||||
if (actual.Count != predicted.Count)
|
||||
throw new ArgumentException("Actual and predicted series must have the same length", nameof(predicted));
|
||||
|
||||
int len = actual.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
|
||||
Batch(actual.Values, predicted.Values, vSpan, period, delta);
|
||||
actual.Times.CopyTo(tSpan);
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> actual, ReadOnlySpan<double> predicted, Span<double> output, int period, double delta = 1.345)
|
||||
{
|
||||
if (actual.Length != predicted.Length || actual.Length != output.Length)
|
||||
throw new ArgumentException("All spans must have the same length", nameof(output));
|
||||
if (period <= 0)
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
if (delta <= 0)
|
||||
throw new ArgumentException("Delta must be greater than 0", nameof(delta));
|
||||
|
||||
int len = actual.Length;
|
||||
if (len == 0) return;
|
||||
|
||||
double halfDeltaSquared = 0.5 * delta * delta;
|
||||
|
||||
const int StackAllocThreshold = 256;
|
||||
Span<double> buffer = period <= StackAllocThreshold
|
||||
? stackalloc double[period]
|
||||
: new double[period];
|
||||
|
||||
double sum = 0;
|
||||
double lastValidActual = 0;
|
||||
double lastValidPredicted = 0;
|
||||
|
||||
for (int k = 0; k < len; k++)
|
||||
{
|
||||
if (double.IsFinite(actual[k])) { lastValidActual = actual[k]; break; }
|
||||
}
|
||||
for (int k = 0; k < len; k++)
|
||||
{
|
||||
if (double.IsFinite(predicted[k])) { lastValidPredicted = predicted[k]; break; }
|
||||
}
|
||||
|
||||
int bufferIndex = 0;
|
||||
int i = 0;
|
||||
|
||||
int warmupEnd = Math.Min(period, len);
|
||||
for (; i < warmupEnd; i++)
|
||||
{
|
||||
double act = actual[i];
|
||||
double pred = predicted[i];
|
||||
|
||||
if (double.IsFinite(act)) lastValidActual = act; else act = lastValidActual;
|
||||
if (double.IsFinite(pred)) lastValidPredicted = pred; else pred = lastValidPredicted;
|
||||
|
||||
double error = act - pred;
|
||||
double absError = Math.Abs(error);
|
||||
double huberLoss = absError <= delta
|
||||
? 0.5 * error * error
|
||||
: delta * absError - halfDeltaSquared;
|
||||
|
||||
sum += huberLoss;
|
||||
buffer[i] = huberLoss;
|
||||
output[i] = sum / (i + 1);
|
||||
}
|
||||
|
||||
int tickCount = 0;
|
||||
for (; i < len; i++)
|
||||
{
|
||||
double act = actual[i];
|
||||
double pred = predicted[i];
|
||||
|
||||
if (double.IsFinite(act)) lastValidActual = act; else act = lastValidActual;
|
||||
if (double.IsFinite(pred)) lastValidPredicted = pred; else pred = lastValidPredicted;
|
||||
|
||||
double error = act - pred;
|
||||
double absError = Math.Abs(error);
|
||||
double huberLoss = absError <= delta
|
||||
? 0.5 * error * error
|
||||
: delta * absError - halfDeltaSquared;
|
||||
|
||||
sum = sum - buffer[bufferIndex] + huberLoss;
|
||||
buffer[bufferIndex] = huberLoss;
|
||||
|
||||
bufferIndex++;
|
||||
if (bufferIndex >= period) bufferIndex = 0;
|
||||
|
||||
output[i] = sum / period;
|
||||
|
||||
tickCount++;
|
||||
if (tickCount >= ResyncInterval)
|
||||
{
|
||||
tickCount = 0;
|
||||
double recalcSum = 0;
|
||||
for (int k = 0; k < period; k++) recalcSum += buffer[k];
|
||||
sum = recalcSum;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
# Huber: Huber Loss
|
||||
|
||||
> "The Goldilocks of loss functions: not too sensitive, not too robust, just right."
|
||||
|
||||
Huber Loss is a hybrid loss function that combines the best properties of Mean Squared Error (MSE) and Mean Absolute Error (MAE). For small errors, it behaves quadratically like MSE; for large errors, it behaves linearly like MAE.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Introduced by Peter J. Huber in 1964 as part of robust statistics, Huber Loss was designed to be less sensitive to outliers than squared error while maintaining the nice mathematical properties of quadratic loss for small errors. The default delta value of 1.345 provides 95% asymptotic efficiency for normally distributed data.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
Huber Loss uses a threshold parameter (delta) to switch between quadratic and linear behavior:
|
||||
|
||||
- **Small errors (|e| ≤ δ)**: Quadratic penalty, like MSE
|
||||
- **Large errors (|e| > δ)**: Linear penalty, like MAE
|
||||
|
||||
This makes it differentiable everywhere (unlike MAE) while being robust to outliers (unlike MSE).
|
||||
|
||||
### Properties
|
||||
|
||||
- **Non-negative**: Huber ≥ 0, with 0 indicating perfect prediction
|
||||
- **Differentiable**: Smooth at the transition point (unlike MAE)
|
||||
- **Robust**: Less sensitive to outliers than MSE
|
||||
- **Configurable**: Delta controls the transition between quadratic and linear
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### 1. Huber Loss Function
|
||||
|
||||
For each error $e = y - \hat{y}$:
|
||||
|
||||
$$L_{\delta}(e) = \begin{cases} \frac{1}{2}e^2 & \text{if } |e| \leq \delta \\ \delta|e| - \frac{1}{2}\delta^2 & \text{if } |e| > \delta \end{cases}$$
|
||||
|
||||
Where:
|
||||
|
||||
- $y$ = actual value
|
||||
- $\hat{y}$ = predicted value
|
||||
- $\delta$ = threshold parameter (default: 1.345)
|
||||
|
||||
### 2. Mean Huber Loss
|
||||
|
||||
Average the individual losses over the period:
|
||||
|
||||
$$\text{Huber} = \frac{1}{n} \sum_{i=1}^{n} L_{\delta}(e_i)$$
|
||||
|
||||
### 3. Running Update (O(1))
|
||||
|
||||
QuanTAlib uses a ring buffer with running sum for O(1) updates:
|
||||
|
||||
$$S_{new} = S_{old} - L_{oldest} + L_{newest}$$
|
||||
|
||||
$$\text{Huber} = \frac{S_{new}}{n}$$
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Usage Patterns
|
||||
|
||||
```csharp
|
||||
// Streaming mode - update with each new observation
|
||||
var huber = new Huber(period: 20, delta: 1.345);
|
||||
var result = huber.Update(actualValue, predictedValue);
|
||||
|
||||
// Batch mode - calculate for entire series
|
||||
var results = Huber.Calculate(actualSeries, predictedSeries, period: 20, delta: 1.345);
|
||||
|
||||
// Span mode - zero-allocation for high performance
|
||||
Huber.Batch(actualSpan, predictedSpan, outputSpan, period: 20, delta: 1.345);
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **period** | int | - | Lookback window for averaging (must be > 0) |
|
||||
| **delta** | double | 1.345 | Threshold for quadratic/linear transition |
|
||||
|
||||
### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **Last** | TValue | Most recent Huber Loss value |
|
||||
| **IsHot** | bool | True when buffer is full |
|
||||
| **Name** | string | Indicator name (e.g., "Huber(20,1.345)") |
|
||||
| **WarmupPeriod** | int | Number of periods before valid output |
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **Throughput** | ~12 ns/bar | O(1) update complexity |
|
||||
| **Allocations** | 0 | Uses pre-allocated ring buffer |
|
||||
| **Complexity** | O(1) | Constant time per update |
|
||||
| **Accuracy** | 10/10 | Exact calculation |
|
||||
| **Timeliness** | 9/10 | No lag beyond the period |
|
||||
| **Smoothness** | 8/10 | Good smoothing properties |
|
||||
|
||||
## Delta Selection Guide
|
||||
|
||||
| Delta Value | Behavior | Use Case |
|
||||
| :--- | :--- | :--- |
|
||||
| **Small (< 1)** | More like MAE | Heavy outlier presence |
|
||||
| **1.345** | 95% efficiency | General purpose (default) |
|
||||
| **Large (> 5)** | More like MSE | Few outliers expected |
|
||||
|
||||
## Comparison with Other Metrics
|
||||
|
||||
| Metric | Outlier Sensitivity | Differentiable | Behavior |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Huber** | Medium | Yes | Hybrid quadratic/linear |
|
||||
| **MAE** | Low | No | Always linear |
|
||||
| **MSE** | High | Yes | Always quadratic |
|
||||
| **RMSE** | High | Yes | Quadratic (same units) |
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
1. **Robust Regression**: Training models with some outliers
|
||||
2. **Financial Forecasting**: When extreme values occur occasionally
|
||||
3. **Signal Processing**: Noise reduction with outlier tolerance
|
||||
4. **Machine Learning**: Loss function for neural networks
|
||||
|
||||
## Behavior Examples
|
||||
|
||||
```csharp
|
||||
// Small error (quadratic region)
|
||||
// Error = 0.5, delta = 1.345
|
||||
// Huber = 0.5 * 0.5² = 0.125
|
||||
var huber = new Huber(1, 1.345);
|
||||
huber.Update(100, 99.5); // Returns 0.125
|
||||
|
||||
// Large error (linear region)
|
||||
// Error = 10, delta = 1.345
|
||||
// Huber = 1.345 * 10 - 0.5 * 1.345² = 13.45 - 0.904 = 12.546
|
||||
huber.Reset();
|
||||
huber.Update(110, 100); // Returns ~12.546
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Identical Values**: Returns 0 when actual equals predicted
|
||||
- **NaN Handling**: Uses last valid value substitution
|
||||
- **Single Input**: Not supported (requires two series)
|
||||
- **Period = 1**: Returns current Huber loss
|
||||
- **Error at delta**: Uses quadratic formula (continuous transition)
|
||||
|
||||
## Related Indicators
|
||||
|
||||
- [MAE](../mae/Mae.md) - Mean Absolute Error (linear everywhere)
|
||||
- [MSE](../mse/Mse.md) - Mean Squared Error (quadratic everywhere)
|
||||
- [RMSE](../rmse/Rmse.md) - Root Mean Squared Error
|
||||
Reference in New Issue
Block a user