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
+378
View File
@@ -0,0 +1,378 @@
using Xunit;
namespace QuanTAlib.Tests;
public class MpeTests
{
private const double Precision = 1e-10;
[Fact]
public void Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Mpe(0));
Assert.Throws<ArgumentException>(() => new Mpe(-1));
var mpe = new Mpe(10);
Assert.NotNull(mpe);
}
[Fact]
public void Calc_ReturnsValue()
{
var mpe = new Mpe(10);
var result = mpe.Update(100.0, 90.0);
Assert.True(double.IsFinite(result.Value));
Assert.Equal(result.Value, mpe.Last.Value);
}
[Fact]
public void ZeroError_ReturnsZero()
{
var mpe = new Mpe(5);
for (int i = 0; i < 5; i++)
{
mpe.Update(100.0, 100.0);
}
Assert.Equal(0.0, mpe.Last.Value, Precision);
}
[Fact]
public void UnderPrediction_ReturnsPositive()
{
// MPE: 100 * (actual - predicted) / actual
// When actual > predicted, result is positive
var mpe = new Mpe(1);
var result = mpe.Update(100.0, 80.0);
// MPE = 100 * (100 - 80) / 100 = 20%
Assert.Equal(20.0, result.Value, Precision);
}
[Fact]
public void OverPrediction_ReturnsNegative()
{
// When actual < predicted, result is negative
var mpe = new Mpe(1);
var result = mpe.Update(100.0, 120.0);
// MPE = 100 * (100 - 120) / 100 = -20%
Assert.Equal(-20.0, result.Value, Precision);
}
[Fact]
public void Period1_ReturnsCurrentError()
{
var mpe = new Mpe(1);
// actual=100, predicted=90 -> MPE = 100 * (100-90)/100 = 10%
var r1 = mpe.Update(100.0, 90.0);
Assert.Equal(10.0, r1.Value, Precision);
// actual=100, predicted=110 -> MPE = 100 * (100-110)/100 = -10%
var r2 = mpe.Update(100.0, 110.0);
Assert.Equal(-10.0, r2.Value, Precision);
}
[Fact]
public void KnownValues_CalculatesCorrectly()
{
var mpe = new Mpe(3);
// actual=100, predicted=90 -> MPE = 10%
mpe.Update(100.0, 90.0);
// actual=100, predicted=110 -> MPE = -10%
mpe.Update(100.0, 110.0);
// actual=100, predicted=100 -> MPE = 0%
mpe.Update(100.0, 100.0);
// Average: (10 + (-10) + 0) / 3 = 0%
Assert.Equal(0.0, mpe.Last.Value, Precision);
}
[Fact]
public void BiasDetection_PositiveBiasAverage()
{
var mpe = new Mpe(3);
// Consistently under-predicting
mpe.Update(100.0, 95.0); // +5%
mpe.Update(100.0, 90.0); // +10%
mpe.Update(100.0, 85.0); // +15%
// Average: (5 + 10 + 15) / 3 = 10%
Assert.Equal(10.0, mpe.Last.Value, Precision);
Assert.True(mpe.Last.Value > 0); // Positive bias
}
[Fact]
public void BiasDetection_NegativeBiasAverage()
{
var mpe = new Mpe(3);
// Consistently over-predicting
mpe.Update(100.0, 105.0); // -5%
mpe.Update(100.0, 110.0); // -10%
mpe.Update(100.0, 115.0); // -15%
// Average: (-5 + -10 + -15) / 3 = -10%
Assert.Equal(-10.0, mpe.Last.Value, Precision);
Assert.True(mpe.Last.Value < 0); // Negative bias
}
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var mpe = new Mpe(5);
mpe.Update(100.0, 90.0);
mpe.Update(100.0, 95.0);
var resultAfterNaN = mpe.Update(double.NaN, 90.0);
Assert.True(double.IsFinite(resultAfterNaN.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var mpe = new Mpe(5);
mpe.Update(100.0, 90.0);
var resultAfterPosInf = mpe.Update(double.PositiveInfinity, 90.0);
Assert.True(double.IsFinite(resultAfterPosInf.Value));
var resultAfterNegInf = mpe.Update(100.0, double.NegativeInfinity);
Assert.True(double.IsFinite(resultAfterNegInf.Value));
}
[Fact]
public void ZeroActual_HandledGracefully()
{
var mpe = new Mpe(5);
mpe.Update(100.0, 90.0);
var result = mpe.Update(0.0, 10.0);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void IsHot_BecomesTrueWhenBufferFull()
{
var mpe = new Mpe(5);
Assert.False(mpe.IsHot);
for (int i = 1; i <= 4; i++)
{
mpe.Update(100.0, 90.0 + i);
Assert.False(mpe.IsHot);
}
mpe.Update(100.0, 95.0);
Assert.True(mpe.IsHot);
}
[Fact]
public void Reset_ClearsState()
{
var mpe = new Mpe(10);
mpe.Update(100.0, 90.0);
mpe.Update(100.0, 95.0);
mpe.Reset();
Assert.Equal(0, mpe.Last.Value);
Assert.False(mpe.IsHot);
}
[Fact]
public void IsNew_False_UpdatesCurrentBar()
{
var mpe = new Mpe(5);
mpe.Update(100.0, 90.0);
double valueBefore = mpe.Last.Value;
mpe.Update(100.0, 95.0, isNew: false);
double valueAfter = mpe.Last.Value;
Assert.NotEqual(valueBefore, valueAfter);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var mpe = new Mpe(5);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
mpe.Update(bar.Close, bar.Close * 0.95, isNew: true);
}
double stateAfterTen = mpe.Last.Value;
var lastBar = gbm.Next(isNew: false);
double lastActual = lastBar.Close;
double lastPredicted = lastBar.Close * 0.95;
for (int i = 0; i < 5; i++)
{
var bar = gbm.Next(isNew: false);
mpe.Update(bar.Close, bar.Close * 0.9, isNew: false);
}
mpe.Update(lastActual, lastPredicted, isNew: false);
Assert.Equal(stateAfterTen, mpe.Last.Value, 1e-6);
}
[Fact]
public void BatchCalc_MatchesIterativeCalc()
{
var mpeIterative = new Mpe(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var actualSeries = new TSeries();
var predictedSeries = new TSeries();
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
actualSeries.Add(bar.Time, bar.Close);
predictedSeries.Add(bar.Time, bar.Close * 0.95);
}
var iterativeResults = new List<double>();
for (int i = 0; i < actualSeries.Count; i++)
{
iterativeResults.Add(mpeIterative.Update(actualSeries[i], predictedSeries[i]).Value);
}
var batchResults = Mpe.Calculate(actualSeries, predictedSeries, 10);
Assert.Equal(iterativeResults.Count, batchResults.Count);
for (int i = 0; i < iterativeResults.Count; i++)
{
Assert.Equal(iterativeResults[i], batchResults[i].Value, Precision);
}
}
[Fact]
public void SpanBatch_ValidatesInput()
{
double[] actual = [100, 100, 100];
double[] predicted = [90, 95, 100];
double[] output = new double[3];
double[] wrongSizeOutput = new double[2];
Assert.Throws<ArgumentException>(() =>
Mpe.Batch(actual.AsSpan(), predicted.AsSpan(), wrongSizeOutput.AsSpan(), 3));
Assert.Throws<ArgumentException>(() =>
Mpe.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 0));
}
[Fact]
public void SpanBatch_MatchesTSeriesBatch()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var actualSeries = new TSeries();
var predictedSeries = new TSeries();
double[] actualArr = new double[100];
double[] predictedArr = new double[100];
double[] output = new double[100];
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
actualArr[i] = bar.Close;
predictedArr[i] = bar.Close * 0.95;
actualSeries.Add(bar.Time, bar.Close);
predictedSeries.Add(bar.Time, bar.Close * 0.95);
}
var tseriesResult = Mpe.Calculate(actualSeries, predictedSeries, 10);
Mpe.Batch(actualArr.AsSpan(), predictedArr.AsSpan(), output.AsSpan(), 10);
for (int i = 0; i < 100; i++)
{
Assert.Equal(tseriesResult[i].Value, output[i], Precision);
}
}
[Fact]
public void SpanBatch_HandlesNaN()
{
double[] actual = [100, 100, double.NaN, 100, 100];
double[] predicted = [90, 95, 92, double.NaN, 95];
double[] output = new double[5];
Mpe.Batch(actual.AsSpan(), predicted.AsSpan(), output.AsSpan(), 3);
foreach (var val in output)
{
Assert.True(double.IsFinite(val), $"Expected finite value but got {val}");
}
}
[Fact]
public void Calculate_MismatchedLengths_ThrowsException()
{
var actual = new TSeries();
var predicted = new TSeries();
actual.Add(DateTime.UtcNow.Ticks, 100);
actual.Add(DateTime.UtcNow.Ticks + 1, 100);
predicted.Add(DateTime.UtcNow.Ticks, 90);
Assert.Throws<ArgumentException>(() => Mpe.Calculate(actual, predicted, 5));
}
[Fact]
public void Name_IsSetCorrectly()
{
var mpe = new Mpe(14);
Assert.Equal("Mpe(14)", mpe.Name);
}
[Fact]
public void WarmupPeriod_IsSetCorrectly()
{
var mpe = new Mpe(20);
Assert.Equal(20, mpe.WarmupPeriod);
}
[Fact]
public void DifferenceFromMape_SignPreserved()
{
// MPE preserves sign, MAPE takes absolute value
var mpe = new Mpe(2);
var mape = new Mape(2);
// Under-prediction: both should be positive
mpe.Update(100.0, 90.0); // +10%
mape.Update(100.0, 90.0); // +10%
// Over-prediction: MPE negative, MAPE positive
mpe.Update(100.0, 110.0); // -10%
mape.Update(100.0, 110.0); // +10%
// MPE average: (10 + (-10)) / 2 = 0
// MAPE average: (10 + 10) / 2 = 10
Assert.Equal(0.0, mpe.Last.Value, Precision);
Assert.Equal(10.0, mape.Last.Value, Precision);
}
[Fact]
public void SlidingWindow_Works()
{
var mpe = new Mpe(3);
mpe.Update(100.0, 90.0); // +10%
mpe.Update(100.0, 95.0); // +5%
mpe.Update(100.0, 100.0); // 0%
// Average: (10 + 5 + 0) / 3 = 5%
Assert.Equal(5.0, mpe.Last.Value, Precision);
mpe.Update(100.0, 105.0); // -5%
// Window now: +5%, 0%, -5%
// Average: (5 + 0 + (-5)) / 3 = 0%
Assert.Equal(0.0, mpe.Last.Value, Precision);
mpe.Update(100.0, 110.0); // -10%
// Window now: 0%, -5%, -10%
// Average: (0 + (-5) + (-10)) / 3 = -5%
Assert.Equal(-5.0, mpe.Last.Value, Precision);
}
}
+227
View File
@@ -0,0 +1,227 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// MPE: Mean Percentage Error
/// </summary>
/// <remarks>
/// MPE measures the average percentage error between actual and predicted values,
/// preserving the sign to detect directional bias. Unlike MAPE, it can reveal
/// systematic over- or under-prediction.
///
/// Formula:
/// MPE = (100/n) * Σ((actual - predicted) / actual)
///
/// Key properties:
/// - Scale-independent (expressed as percentage)
/// - Preserves sign: positive = under-prediction, negative = over-prediction
/// - Cannot be calculated when actual = 0
/// - Useful for detecting systematic bias in predictions
/// </remarks>
[SkipLocalsInit]
public sealed class Mpe : 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 Mpe(int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_buffer = new RingBuffer(period);
Name = $"Mpe({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;
// Avoid division by zero
double divisor = Math.Abs(actualVal) < 1e-10 ? 1e-10 : actualVal;
// MPE preserves sign (no Math.Abs on the error)
double percentageError = 100.0 * ((actualVal - predictedVal) / divisor);
if (isNew)
{
_p_state = _state;
double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0;
_state.Sum = _state.Sum - removedValue + percentageError;
_buffer.Add(percentageError);
_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 + percentageError;
_buffer.UpdateNewest(percentageError);
_state.Sum = _buffer.RecalculateSum();
}
double result = _buffer.Count > 0 ? _state.Sum / _buffer.Count : percentageError;
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("MPE requires two inputs. Use Update(actual, predicted).");
}
public override TSeries Update(TSeries source)
{
throw new NotSupportedException("MPE requires two inputs. Use Calculate(actualSeries, predictedSeries, period).");
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
throw new NotSupportedException("MPE 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 divisor = Math.Abs(act) < 1e-10 ? 1e-10 : act;
double percentageError = 100.0 * ((act - pred) / divisor);
sum += percentageError;
buffer[i] = percentageError;
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 divisor = Math.Abs(act) < 1e-10 ? 1e-10 : act;
double percentageError = 100.0 * ((act - pred) / divisor);
sum = sum - buffer[bufferIndex] + percentageError;
buffer[bufferIndex] = percentageError;
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;
}
}
}
}
+141
View File
@@ -0,0 +1,141 @@
# MPE: Mean Percentage Error
> "MAPE tells you how wrong you are; MPE tells you which direction you're wrong in."
Mean Percentage Error measures the average percentage difference between actual and predicted values while preserving the sign. Unlike MAPE, which takes absolute values, MPE reveals systematic bias in predictions—whether a model consistently over-predicts or under-predicts.
## Architecture & Physics
MPE computes the signed percentage error for each data point and averages over a rolling window:
$$\text{MPE} = \frac{100}{n} \sum_{i=1}^{n} \frac{(\text{actual}_i - \text{predicted}_i)}{\text{actual}_i}$$
The sign preservation makes MPE invaluable for bias detection:
- **Positive MPE**: Model systematically under-predicts (actual > predicted)
- **Negative MPE**: Model systematically over-predicts (actual < predicted)
- **MPE near zero**: No systematic bias (though individual errors may be large)
### Bias Detection
Consider a weather forecasting model:
- If MPE = +15%, the model consistently predicts temperatures 15% lower than actual
- If MPE = -10%, the model consistently predicts temperatures 10% higher than actual
- If MPE ≈ 0% but MAPE = 20%, errors cancel out (no bias) but magnitude is still significant
## Mathematical Foundation
### 1. Point-wise Percentage Error
For each observation:
$$e_i = 100 \times \frac{\text{actual}_i - \text{predicted}_i}{\text{actual}_i}$$
### 2. Rolling Average
Over a period $n$:
$$\text{MPE}_t = \frac{1}{n} \sum_{i=t-n+1}^{t} e_i$$
### 3. Relationship to MAPE
$$\text{MAPE} = \frac{100}{n} \sum |e_i / 100|$$
$$\text{MPE} = \frac{100}{n} \sum (e_i / 100)$$
When errors are consistently in one direction: $|\text{MPE}| \approx \text{MAPE}$
When errors alternate: $|\text{MPE}| < \text{MAPE}$
## Performance Profile
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Throughput** | 15 ns/bar | O(1) via running sum |
| **Allocations** | 0 | Zero-allocation hot path |
| **Complexity** | O(1) | Constant per update |
| **Bias Detection** | 10/10 | Primary strength |
| **Magnitude Info** | 3/10 | Errors can cancel |
| **Scale Independence** | 9/10 | Percentage-based |
| **Outlier Sensitivity** | 5/10 | Linear in error magnitude |
## Usage
```csharp
// Streaming mode - bias detection in real-time
var mpe = new Mpe(20);
// Actual values consistently higher than predictions
mpe.Update(actual: 105.0, predicted: 100.0); // +5%
mpe.Update(actual: 110.0, predicted: 100.0); // +10%
// MPE will be positive, indicating under-prediction bias
double currentBias = mpe.Last.Value;
if (currentBias > 5.0)
Console.WriteLine("Model is under-predicting by {0:F1}%", currentBias);
else if (currentBias < -5.0)
Console.WriteLine("Model is over-predicting by {0:F1}%", Math.Abs(currentBias));
else
Console.WriteLine("Model shows no significant bias");
// Batch mode - analyze historical predictions
var actual = new TSeries { 100, 105, 98, 102, 101 };
var predicted = new TSeries { 95, 100, 95, 100, 100 };
var results = Mpe.Calculate(actual, predicted, period: 3);
// Span mode - zero-allocation bulk processing
Span<double> output = stackalloc double[1000];
Mpe.Batch(actualSpan, predictedSpan, output, period: 20);
```
## Interpretation Guide
| MPE Value | Interpretation | Action |
| :--- | :--- | :--- |
| **> +10%** | Severe under-prediction | Add positive bias correction |
| **+5% to +10%** | Moderate under-prediction | Consider model recalibration |
| **-5% to +5%** | Acceptable bias range | Monitor for drift |
| **-10% to -5%** | Moderate over-prediction | Consider model recalibration |
| **< -10%** | Severe over-prediction | Add negative bias correction |
## Comparison with Related Metrics
| Metric | Formula | Preserves Sign | Use Case |
| :--- | :--- | :--- | :--- |
| **MPE** | 100 × (A-P)/A | ✓ | Bias detection |
| **MAPE** | 100 × \|A-P\|/A | ✗ | Magnitude only |
| **ME** | A - P | ✓ | Absolute bias |
| **MAE** | \|A - P\| | ✗ | Absolute magnitude |
## Common Pitfalls
### 1. Zero Actuals
MPE is undefined when actual = 0. The implementation uses epsilon fallback:
```csharp
double divisor = Math.Abs(actual) < 1e-10 ? 1e-10 : actual;
```
### 2. Cancellation Effect
Errors of opposite signs cancel out. A model alternating between +50% and -50% errors would show MPE ≈ 0%, masking severe inaccuracy.
**Solution**: Use MPE alongside MAPE:
- Low MAPE + Low |MPE|: Good model
- Low MAPE + High |MPE|: Unlikely (mathematically constrained)
- High MAPE + Low |MPE|: High variance, no bias
- High MAPE + High |MPE|: High variance with bias
### 3. Asymmetric Bounds
Unlike MAPE (bounded at 0% to ∞), MPE can range from -∞ to +100%:
- Maximum positive: actual = 100, predicted = 0 → MPE = +100%
- No upper bound on negative: actual = 100, predicted = 1000 → MPE = -900%
## See Also
- [MAPE](../mape/Mape.md) - Unsigned percentage error for magnitude
- [ME](../me/Me.md) - Signed absolute error for absolute bias
- [MAE](../mae/Mae.md) - Unsigned absolute error for magnitude