Add Tukey's Biweight and WMAPE implementations with comprehensive tests and documentation

- Introduced Tukey's Biweight as a robust loss function, including mathematical foundation, usage patterns, and performance profile.
- Added WMAPE (Weighted Mean Absolute Percentage Error) implementation, emphasizing its advantages for intermittent demand forecasting.
- Created unit tests for WMAPE covering various scenarios including edge cases and batch calculations.
- Documented both Tukey's Biweight and WMAPE with detailed explanations, properties, and common use cases.
This commit is contained in:
Miha Kralj
2025-12-30 09:27:08 -08:00
parent bf611d319f
commit 6e24fea8b7
35 changed files with 8341 additions and 206 deletions
+333
View File
@@ -0,0 +1,333 @@
namespace QuanTAlib.Tests;
public class MraeTests
{
[Fact]
public void Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Mrae(0));
Assert.Throws<ArgumentException>(() => new Mrae(-1));
var mrae = new Mrae(10);
Assert.NotNull(mrae);
}
[Fact]
public void Properties_Accessible()
{
var mrae = new Mrae(10);
Assert.Equal(0, mrae.Last.Value);
Assert.False(mrae.IsHot);
Assert.Contains("Mrae", mrae.Name, StringComparison.Ordinal);
mrae.Update(100, 105);
Assert.NotEqual(0, mrae.Last.Time);
}
[Fact]
public void IsHot_BecomesTrueWhenBufferFull()
{
int period = 5;
var mrae = new Mrae(period);
for (int i = 1; i <= period - 1; i++)
{
Assert.False(mrae.IsHot, $"IsHot should be false at index {i}");
mrae.Update(i * 10, i * 10 + 5);
}
mrae.Update(period * 10, period * 10 + 5);
Assert.True(mrae.IsHot, "IsHot should be true after period updates");
}
[Fact]
public void Mrae_CalculatesCorrectly()
{
var mrae = new Mrae(3);
// |100 - 110| / |100| = 10/100 = 0.1
var res1 = mrae.Update(100, 110);
Assert.Equal(0.1, res1.Value, 10);
// |200 - 220| / |200| = 20/200 = 0.1, Mean = (0.1 + 0.1) / 2 = 0.1
var res2 = mrae.Update(200, 220);
Assert.Equal(0.1, res2.Value, 10);
// |50 - 60| / |50| = 10/50 = 0.2, Mean = (0.1 + 0.1 + 0.2) / 3 = 0.133...
var res3 = mrae.Update(50, 60);
Assert.Equal(0.4 / 3.0, res3.Value, 10);
}
[Fact]
public void Mrae_PerfectPrediction_ReturnsZero()
{
var mrae = new Mrae(5);
for (int i = 1; i <= 10; i++)
{
mrae.Update(i * 10, i * 10); // Perfect prediction
}
Assert.Equal(0.0, mrae.Last.Value, 10);
}
[Fact]
public void Mrae_ProportionalError_ReturnsConstant()
{
var mrae = new Mrae(5);
// 10% error for all
for (int i = 1; i <= 10; i++)
{
mrae.Update(i * 100, i * 110); // 10% overestimate
}
Assert.Equal(0.1, mrae.Last.Value, 10);
}
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var mrae = new Mrae(10);
mrae.Update(100, 110, isNew: true);
double value1 = mrae.Last.Value;
mrae.Update(100, 120, isNew: true);
double value2 = mrae.Last.Value;
Assert.NotEqual(value1, value2);
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var mrae = new Mrae(10);
mrae.Update(100, 110);
mrae.Update(100, 120, isNew: true);
double beforeUpdate = mrae.Last.Value;
mrae.Update(100, 130, isNew: false);
double afterUpdate = mrae.Last.Value;
Assert.NotEqual(beforeUpdate, afterUpdate);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var mrae = new Mrae(5);
double tenthActual = 0;
double tenthPredicted = 0;
// Feed 10 updates
for (int i = 1; i <= 10; i++)
{
tenthActual = i * 100;
tenthPredicted = i * 100 + 10;
mrae.Update(tenthActual, tenthPredicted);
}
double stateAfterTen = mrae.Last.Value;
// Apply 5 corrections with isNew=false
for (int i = 0; i < 5; i++)
{
mrae.Update(100 + i, 200 + i, isNew: false);
}
// Restore to original values
mrae.Update(tenthActual, tenthPredicted, isNew: false);
Assert.Equal(stateAfterTen, mrae.Last.Value, 10);
}
[Fact]
public void Reset_ClearsState()
{
var mrae = new Mrae(5);
for (int i = 1; i <= 10; i++)
{
mrae.Update(i * 10, i * 10 + 5);
}
Assert.True(mrae.IsHot);
mrae.Reset();
Assert.False(mrae.IsHot);
Assert.Equal(0, mrae.Last.Value);
}
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var mrae = new Mrae(5);
mrae.Update(100, 110);
mrae.Update(110, 120);
mrae.Update(120, 130);
var result = mrae.Update(double.NaN, double.NaN);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var mrae = new Mrae(5);
mrae.Update(100, 110);
mrae.Update(110, 120);
var result = mrae.Update(double.PositiveInfinity, double.NegativeInfinity);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void MultipleNaN_ContinuesWithLastValid()
{
var mrae = new Mrae(5);
mrae.Update(100, 110);
mrae.Update(110, 120);
mrae.Update(120, 130);
var r1 = mrae.Update(double.NaN, double.NaN);
var r2 = mrae.Update(double.NaN, double.NaN);
var r3 = mrae.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 Mrae_Throws_On_Single_Input()
{
var mrae = new Mrae(10);
Assert.Throws<NotSupportedException>(() => mrae.Update(new TValue(DateTime.UtcNow, 1)));
Assert.Throws<NotSupportedException>(() => mrae.Update(new TSeries()));
Assert.Throws<NotSupportedException>(() => mrae.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;
}
// Streaming
var mrae = new Mrae(period);
var streamingResults = new double[count];
for (int i = 0; i < count; i++)
{
streamingResults[i] = mrae.Update(actual[i], predicted[i]).Value;
}
// Batch
double[] batchResults = new double[count];
Mrae.Batch(actual, predicted, batchResults, period);
// Compare
for (int i = 0; i < count; i++)
{
Assert.Equal(streamingResults[i], batchResults[i], 9);
}
}
[Fact]
public void BatchSpan_ValidatesInput()
{
double[] actual = [10, 20, 30, 40, 50];
double[] predicted = [11, 22, 33, 44, 55];
double[] output = new double[5];
double[] wrongSizeOutput = new double[3];
double[] wrongSizePredicted = new double[3];
Assert.Throws<ArgumentException>(() =>
Mrae.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() =>
Mrae.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), -1));
Assert.Throws<ArgumentException>(() =>
Mrae.Batch(actual.AsSpan(), predicted.AsSpan(), wrongSizeOutput.AsSpan(), 3));
Assert.Throws<ArgumentException>(() =>
Mrae.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 = 1; i <= 10; i++)
{
actual.Add(now.AddMinutes(i), i * 100);
predicted.Add(now.AddMinutes(i), i * 110); // 10% error
}
var results = Mrae.Calculate(actual, predicted, 3);
Assert.Equal(10, results.Count);
Assert.Equal(0.1, results.Last.Value, 10);
}
[Fact]
public void Calculate_ValidatesMismatchedLengths()
{
var actual = new TSeries();
var predicted = new TSeries();
for (int i = 1; i <= 10; i++) actual.Add(DateTime.UtcNow, i * 10);
for (int i = 1; i <= 5; i++) predicted.Add(DateTime.UtcNow, i * 10);
Assert.Throws<ArgumentException>(() => Mrae.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];
Mrae.Batch(actual, predicted, output, 3);
foreach (var val in output)
{
Assert.True(double.IsFinite(val), $"Expected finite value but got {val}");
}
}
[Fact]
public void Mrae_Resync_Works()
{
var mrae = new Mrae(5);
// Force many updates to trigger resync
for (int i = 1; i <= 1100; i++)
{
mrae.Update(100, 110); // 10% error
}
Assert.Equal(0.1, mrae.Last.Value, 10);
}
}
+228
View File
@@ -0,0 +1,228 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// MRAE: Mean Relative Absolute Error
/// </summary>
/// <remarks>
/// MRAE measures the average relative absolute error, normalizing each error
/// by the absolute actual value. Similar to MAPE but computes the ratio differently.
///
/// Formula:
/// MRAE = (1/n) * Σ(|actual - predicted| / |actual|)
///
/// Key properties:
/// - Scale-independent through normalization
/// - Handles signs differently than MAPE
/// - Undefined when actual = 0 (uses epsilon protection)
/// - Values typically between 0 and 1 (0 = perfect, 1 = 100% error)
/// </remarks>
[SkipLocalsInit]
public sealed class Mrae : 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;
public Mrae(int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_buffer = new RingBuffer(period);
Name = $"Mrae({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 : 1.0;
else
_state.LastValidActual = actualVal;
if (!double.IsFinite(predictedVal))
predictedVal = double.IsFinite(_state.LastValidPredicted) ? _state.LastValidPredicted : 0.0;
else
_state.LastValidPredicted = predictedVal;
// MRAE: |actual - predicted| / |actual|
double absActual = Math.Abs(actualVal);
double absError = Math.Abs(actualVal - predictedVal);
double relativeError = absActual > 1e-10 ? absError / absActual : 0.0;
if (isNew)
{
_p_state = _state;
double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
_state.Sum = _state.Sum - removedValue + relativeError;
_buffer.Add(relativeError);
_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 + relativeError;
_buffer.UpdateNewest(relativeError);
_state.Sum = _buffer.RecalculateSum();
}
double result = _buffer.Count > 0 ? _state.Sum / _buffer.Count : relativeError;
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("MRAE requires two inputs. Use Update(actual, predicted).");
}
public override TSeries Update(TSeries source)
{
throw new NotSupportedException("MRAE requires two inputs. Use Calculate(actualSeries, predictedSeries, period).");
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
throw new NotSupportedException("MRAE 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;
const int StackAllocThreshold = 256;
Span<double> buffer = period <= StackAllocThreshold
? stackalloc double[period]
: new double[period];
double sum = 0;
double lastValidActual = 1.0;
double lastValidPredicted = 0;
for (int k = 0; k < len; k++)
{
if (double.IsFinite(actual[k]) && Math.Abs(actual[k]) >= 1e-10) { 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) && Math.Abs(act) >= 1e-10) lastValidActual = act; else act = lastValidActual;
if (double.IsFinite(pred)) lastValidPredicted = pred; else pred = lastValidPredicted;
double absActual = Math.Abs(act);
double absError = Math.Abs(act - pred);
double relativeError = absActual > 1e-10 ? absError / absActual : 0.0;
sum += relativeError;
buffer[i] = relativeError;
output[i] = sum / (i + 1);
}
int tickCount = 0;
for (; i < len; i++)
{
double act = actual[i];
double pred = predicted[i];
if (double.IsFinite(act) && Math.Abs(act) >= 1e-10) lastValidActual = act; else act = lastValidActual;
if (double.IsFinite(pred)) lastValidPredicted = pred; else pred = lastValidPredicted;
double absActual = Math.Abs(act);
double absError = Math.Abs(act - pred);
double relativeError = absActual > 1e-10 ? absError / absActual : 0.0;
sum = sum - buffer[bufferIndex] + relativeError;
buffer[bufferIndex] = relativeError;
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;
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
# MRAE: Mean Relative Absolute Error
> "When you need to understand your error in the context of what you're predicting."
Mean Relative Absolute Error (MRAE) measures the average magnitude of errors relative to the actual values. This normalization makes the metric scale-independent and easier to interpret across different datasets.
## Historical Context
MRAE emerged as an alternative to MAPE for situations where relative error measurement is important but where the issues with percentage-based metrics (like undefined values when actuals are zero) need to be handled differently. It provides a bounded, interpretable measure of prediction accuracy.
## Architecture & Physics
MRAE divides each absolute error by the actual value, providing context for the error magnitude. The error of 5 means something different when predicting 10 versus predicting 1000, and MRAE captures this distinction.
### Properties
- **Scale-independent**: Comparable across different data magnitudes
- **Non-negative**: MRAE ≥ 0, with 0 indicating perfect prediction
- **Interpretable**: A value of 0.1 means 10% average relative error
- **Denominator sensitivity**: Undefined when actual values are zero (handled via substitution)
## Mathematical Foundation
### 1. Relative Absolute Error
For each observation, calculate the relative error:
$$e_i = \frac{|y_i - \hat{y}_i|}{|y_i|}$$
Where:
- $y_i$ = actual value
- $\hat{y}_i$ = predicted value
### 2. Mean Calculation
Average the relative errors over the period:
$$MRAE = \frac{1}{n} \sum_{i=1}^{n} \frac{|y_i - \hat{y}_i|}{|y_i|}$$
### 3. Running Update (O(1))
QuanTAlib uses a ring buffer with running sum for O(1) updates:
$$S_{new} = S_{old} - e_{oldest} + e_{newest}$$
$$MRAE = \frac{S_{new}}{n}$$
## Implementation Details
### Usage Patterns
```csharp
// Streaming mode - update with each new observation
var mrae = new Mrae(period: 20);
var result = mrae.Update(actualValue, predictedValue);
// Batch mode - calculate for entire series
var results = Mrae.Calculate(actualSeries, predictedSeries, period: 20);
// Span mode - zero-allocation for high performance
Mrae.Batch(actualSpan, predictedSpan, outputSpan, period: 20);
```
### Parameters
| Parameter | Type | Description |
| :--- | :--- | :--- |
| **period** | int | Lookback window for averaging (must be > 0) |
### Properties
| Property | Type | Description |
| :--- | :--- | :--- |
| **Last** | TValue | Most recent MRAE value |
| **IsHot** | bool | True when buffer is full |
| **Name** | string | Indicator name (e.g., "Mrae(20)") |
| **WarmupPeriod** | int | Number of periods before valid output |
## Performance Profile
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Throughput** | ~15 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** | 7/10 | Moderate smoothing |
## Interpretation
| MRAE Range | Interpretation |
| :--- | :--- |
| **0** | Perfect prediction |
| **0 - 0.1** | Excellent (< 10% average relative error) |
| **0.1 - 0.3** | Good (10-30% average relative error) |
| **> 0.3** | Poor (> 30% average relative error) |
## Comparison with Other Metrics
| Metric | Scale-Independent | Zero-Safe | Symmetry |
| :--- | :--- | :--- | :--- |
| **MRAE** | Yes | No (uses substitution) | No |
| **MAPE** | Yes | No | No |
| **MAE** | No | Yes | Yes |
| **SMAPE** | Yes | Partially | Yes |
## Common Use Cases
1. **Financial Forecasting**: Compare prediction accuracy across different asset prices
2. **Demand Forecasting**: Normalize errors across products with varying sales volumes
3. **Model Comparison**: Compare models on datasets with different scales
4. **Time Series Analysis**: Track relative prediction quality over time
## Edge Cases
- **Zero Actual Values**: Substitutes with small epsilon (1e-10) to avoid division by zero
- **NaN Handling**: Uses last valid value substitution
- **Single Input**: Not supported (requires two series)
- **Period = 1**: Returns current relative absolute error
## Related Indicators
- [MAE](../mae/Mae.md) - Mean Absolute Error (non-relative)
- [MAPE](../mape/Mape.md) - Mean Absolute Percentage Error
- [SMAPE](../smape/Smape.md) - Symmetric Mean Absolute Percentage Error