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:
Miha Kralj
2025-12-29 20:58:21 -08:00
parent 4dbb093892
commit bf611d319f
50 changed files with 11327 additions and 21 deletions
+250
View File
@@ -0,0 +1,250 @@
namespace QuanTAlib.Tests;
public class RmseTests
{
[Fact]
public void Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Rmse(0));
Assert.Throws<ArgumentException>(() => new Rmse(-1));
var rmse = new Rmse(10);
Assert.NotNull(rmse);
}
[Fact]
public void Properties_Accessible()
{
var rmse = new Rmse(10);
Assert.Equal(0, rmse.Last.Value);
Assert.False(rmse.IsHot);
Assert.Contains("Rmse", rmse.Name, StringComparison.Ordinal);
rmse.Update(100, 105);
Assert.NotEqual(0, rmse.Last.Time);
}
[Fact]
public void IsHot_BecomesTrueWhenBufferFull()
{
int period = 5;
var rmse = new Rmse(period);
for (int i = 0; i < period - 1; i++)
{
Assert.False(rmse.IsHot);
rmse.Update(i * 10, i * 10 + 5);
}
rmse.Update((period - 1) * 10, (period - 1) * 10 + 5);
Assert.True(rmse.IsHot);
}
[Fact]
public void Rmse_CalculatesCorrectly()
{
var rmse = new Rmse(3);
// (10 - 15)² = 25, RMSE = √25 = 5
var res1 = rmse.Update(10, 15);
Assert.Equal(5.0, res1.Value, 10);
// (20 - 30)² = 100, MSE = (25 + 100) / 2 = 62.5, RMSE = √62.5
var res2 = rmse.Update(20, 30);
Assert.Equal(Math.Sqrt(62.5), res2.Value, 10);
// (30 - 25)² = 25, MSE = (25 + 100 + 25) / 3 = 50, RMSE = √50
var res3 = rmse.Update(30, 25);
Assert.Equal(Math.Sqrt(50.0), res3.Value, 10);
}
[Fact]
public void Rmse_IsSqrtOfMse()
{
var rmse = new Rmse(5);
var mse = new Mse(5);
for (int i = 0; i < 20; i++)
{
rmse.Update(i * 10, i * 10 + 7);
mse.Update(i * 10, i * 10 + 7);
}
Assert.Equal(Math.Sqrt(mse.Last.Value), rmse.Last.Value, 10);
}
[Fact]
public void Rmse_PerfectPrediction_ReturnsZero()
{
var rmse = new Rmse(5);
for (int i = 0; i < 10; i++)
{
rmse.Update(i * 10, i * 10);
}
Assert.Equal(0.0, rmse.Last.Value, 10);
}
[Fact]
public void Rmse_ConstantError_ReturnsSameAsError()
{
var rmse = new Rmse(5);
for (int i = 0; i < 10; i++)
{
rmse.Update(100, 110); // Constant error of 10
}
// MSE = 100, RMSE = √100 = 10 (same as error because error is constant)
Assert.Equal(10.0, rmse.Last.Value, 10);
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var rmse = new Rmse(10);
rmse.Update(100, 110);
rmse.Update(100, 120, isNew: true);
double beforeUpdate = rmse.Last.Value;
rmse.Update(100, 130, isNew: false);
double afterUpdate = rmse.Last.Value;
Assert.NotEqual(beforeUpdate, afterUpdate);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var rmse = new Rmse(5);
double tenthActual = 0;
double tenthPredicted = 0;
for (int i = 0; i < 10; i++)
{
tenthActual = i * 10;
tenthPredicted = i * 10 + 5;
rmse.Update(tenthActual, tenthPredicted);
}
double stateAfterTen = rmse.Last.Value;
for (int i = 0; i < 5; i++)
{
rmse.Update(100 + i, 200 + i, isNew: false);
}
rmse.Update(tenthActual, tenthPredicted, isNew: false);
Assert.Equal(stateAfterTen, rmse.Last.Value, 10);
}
[Fact]
public void Reset_ClearsState()
{
var rmse = new Rmse(5);
for (int i = 0; i < 10; i++)
{
rmse.Update(i * 10, i * 10 + 5);
}
Assert.True(rmse.IsHot);
rmse.Reset();
Assert.False(rmse.IsHot);
Assert.Equal(0, rmse.Last.Value);
}
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var rmse = new Rmse(5);
rmse.Update(100, 110);
rmse.Update(110, 120);
var result = rmse.Update(double.NaN, double.NaN);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Rmse_Throws_On_Single_Input()
{
var rmse = new Rmse(10);
Assert.Throws<NotSupportedException>(() => rmse.Update(new TValue(DateTime.UtcNow, 1)));
Assert.Throws<NotSupportedException>(() => rmse.Update(new TSeries()));
Assert.Throws<NotSupportedException>(() => rmse.Prime(new double[] { 1, 2, 3 }));
}
[Fact]
public void BatchSpan_MatchesStreaming()
{
int period = 5;
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;
}
var rmse = new Rmse(period);
var streamingResults = new double[count];
for (int i = 0; i < count; i++)
{
streamingResults[i] = rmse.Update(actual[i], predicted[i]).Value;
}
double[] batchResults = new double[count];
Rmse.Batch(actual, predicted, batchResults, period);
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];
Assert.Throws<ArgumentException>(() =>
Rmse.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() =>
Rmse.Batch(actual.AsSpan(), predicted.AsSpan(), new double[3].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), i * 10);
predicted.Add(now.AddMinutes(i), i * 10 + 5);
}
var results = Rmse.Calculate(actual, predicted, 3);
Assert.Equal(10, results.Count);
// All errors are 5, MSE = 25, RMSE = 5
Assert.Equal(5.0, results.Last.Value, 10);
}
}
+239
View File
@@ -0,0 +1,239 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// RMSE: Root Mean Squared Error
/// </summary>
/// <remarks>
/// RMSE is the square root of MSE, bringing the error metric back to the
/// original units of the data while retaining the outlier sensitivity
/// of squared errors.
///
/// Formula:
/// RMSE = √((1/n) * Σ(actual - predicted)²) = √MSE
///
/// Uses a RingBuffer for O(1) streaming updates with running sum.
///
/// Key properties:
/// - Always non-negative (RMSE ≥ 0)
/// - Same units as the original data
/// - Heavily penalizes outliers due to squaring before averaging
/// - RMSE = 0 indicates perfect prediction
/// </remarks>
[SkipLocalsInit]
public sealed class Rmse : AbstractBase
{
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;
/// <summary>
/// Creates RMSE with specified period.
/// </summary>
/// <param name="period">Number of values to average (must be > 0)</param>
public Rmse(int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_buffer = new RingBuffer(period);
Name = $"Rmse({period})";
WarmupPeriod = period;
}
public override bool IsHot => _buffer.IsFull;
[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 diff = actualVal - predictedVal;
double squaredError = diff * diff;
if (isNew)
{
_p_state = _state;
double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
_state.Sum = _state.Sum - removedValue + squaredError;
_buffer.Add(squaredError);
_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 + squaredError;
_buffer.UpdateNewest(squaredError);
_state.Sum = _buffer.RecalculateSum();
}
double mse = _buffer.Count > 0 ? _state.Sum / _buffer.Count : squaredError;
double result = Math.Sqrt(mse);
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("RMSE requires two inputs. Use Update(actual, predicted).");
}
public override TSeries Update(TSeries source)
{
throw new NotSupportedException("RMSE requires two inputs. Use Calculate(actualSeries, predictedSeries, period).");
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
throw new NotSupportedException("RMSE 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)
{
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);
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)
{
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));
int len = actual.Length;
if (len == 0) return;
CalculateScalarCore(actual, predicted, output, period);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateScalarCore(ReadOnlySpan<double> actual, ReadOnlySpan<double> predicted, Span<double> output, int period)
{
int len = actual.Length;
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 diff = act - pred;
double error = diff * diff;
sum += error;
buffer[i] = error;
output[i] = Math.Sqrt(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 diff = act - pred;
double error = diff * diff;
sum = sum - buffer[bufferIndex] + error;
buffer[bufferIndex] = error;
bufferIndex++;
if (bufferIndex >= period) bufferIndex = 0;
output[i] = Math.Sqrt(sum / period);
tickCount++;
if (tickCount >= ResyncInterval)
{
tickCount = 0;
double recalcSum = 0;
for (int k = 0; k < period; k++) recalcSum += buffer[k];
sum = recalcSum;
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
# RMSE: Root Mean Squared Error
> "MSE's more interpretable sibling that speaks the language of your data."
Root Mean Squared Error (RMSE) is the square root of MSE, providing an error metric in the same units as the original data while retaining sensitivity to large errors.
## Mathematical Foundation
### Formula
$$RMSE = \sqrt{\frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2} = \sqrt{MSE}$$
## Properties
- **Non-negative**: RMSE ≥ 0
- **Same units**: Unlike MSE, RMSE is in original data units
- **Outlier sensitive**: Inherits MSE's penalty for large errors
- **Always ≥ MAE**: RMSE ≥ MAE due to Jensen's inequality
## Usage
```csharp
var rmse = new Rmse(period: 20);
var result = rmse.Update(actualValue, predictedValue);
// Batch calculation
var results = Rmse.Calculate(actualSeries, predictedSeries, period: 20);
```
## Performance Profile
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Throughput** | ~15 ns/bar | O(1) with sqrt operation |
| **Allocations** | 0 | Pre-allocated ring buffer |
| **Complexity** | O(1) | Constant time per update |
## Related Indicators
- [MSE](../mse/Mse.md) - Mean Squared Error
- [MAE](../mae/Mae.md) - Mean Absolute Error