SIMD Refactor: Merge simd-dev into dev (#55)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
Co-authored-by: Warp <agent@warp.dev>
This commit is contained in:
Miha Kralj
2026-01-18 19:02:03 -08:00
committed by GitHub
co-authored by Claude Opus 4.5 aider Warp
parent 5bcdf8d614
commit 86fe32a682
1750 changed files with 198235 additions and 80539 deletions
+406
View File
@@ -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()
{
const 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([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);
}
}
+115
View File
@@ -0,0 +1,115 @@
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 : BiInputIndicatorBase
{
private readonly double _negHalfDeltaSquared;
/// <summary>
/// Gets the delta parameter (transition threshold).
/// </summary>
public double Delta { get; }
/// <summary>
/// Creates a Huber Loss indicator.
/// </summary>
/// <param name="period">Number of values to average (must be > 0)</param>
/// <param name="delta">Threshold for switching between quadratic and linear loss (must be > 0). Default 1.345</param>
public Huber(int period, double delta = 1.345)
: base(period, $"Huber({period},{delta:F3})")
{
if (delta <= 0)
throw new ArgumentException("Delta must be greater than 0", nameof(delta));
Delta = delta;
_negHalfDeltaSquared = -0.5 * delta * delta;
}
/// <summary>
/// Computes Huber loss for a single error value.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override double ComputeError(double actual, double predicted)
{
double diff = actual - predicted;
double absDiff = Math.Abs(diff);
// Quadratic for small errors, linear for large errors
// Linear: delta * |diff| - 0.5 * delta² = FMA(delta, |diff|, -0.5*delta²)
return absDiff <= Delta
? 0.5 * diff * diff
: Math.FusedMultiplyAdd(Delta, absDiff, _negHalfDeltaSquared);
}
/// <summary>
/// Calculates Huber Loss for two time series.
/// </summary>
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);
}
/// <summary>
/// Batch computation of Huber Loss using SIMD-accelerated error computation.
/// </summary>
[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;
// Pre-compute Huber errors using shared helper
const int StackAllocThreshold = 256;
Span<double> errors = len <= StackAllocThreshold
? stackalloc double[len]
: new double[len];
ErrorHelpers.ComputeHuberErrors(actual, predicted, errors, delta);
// Apply rolling mean
ErrorHelpers.ApplyRollingMean(errors, output, period);
}
}
+150
View File
@@ -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