Skip to content

Commit 230585e

Browse files
committed
IQR multiplier
1 parent b189a1e commit 230585e

3 files changed

Lines changed: 51 additions & 103 deletions

File tree

app/components/Package/TrendsChart.vue

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,49 +1708,21 @@ watch(selectedMetric, value => {
17081708
/>
17091709
Filters
17101710
</button>
1711-
<div v-if="showFilterControls" class="grid grid-cols-1 sm:grid-cols-3 gap-3">
1711+
<div v-if="showFilterControls" class="max-w-xs">
17121712
<label class="flex flex-col gap-1">
17131713
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1714-
Hampel window
1715-
<span class="text-fg-muted">({{ settings.chartFilter.hampelWindow }})</span>
1714+
Outlier sensitivity
1715+
<span class="text-fg-muted">({{ settings.chartFilter.iqrMultiplier }})</span>
17161716
</span>
17171717
<input
1718-
v-model.number="settings.chartFilter.hampelWindow"
1718+
v-model.number="settings.chartFilter.iqrMultiplier"
17191719
type="range"
17201720
min="0"
1721-
max="13"
1722-
step="1"
1723-
class="accent-[var(--accent-color,var(--fg-subtle))]"
1724-
/>
1725-
</label>
1726-
<label class="flex flex-col gap-1">
1727-
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1728-
Outlier threshold
1729-
<span class="text-fg-muted">({{ settings.chartFilter.hampelThreshold }})</span>
1730-
</span>
1731-
<input
1732-
v-model.number="settings.chartFilter.hampelThreshold"
1733-
type="range"
1734-
min="0"
1735-
max="10"
1721+
max="5"
17361722
step="0.5"
17371723
class="accent-[var(--accent-color,var(--fg-subtle))]"
17381724
/>
17391725
</label>
1740-
<label class="flex flex-col gap-1">
1741-
<span class="text-2xs font-mono text-fg-subtle tracking-wide uppercase">
1742-
Smoothing (tau)
1743-
<span class="text-fg-muted">({{ settings.chartFilter.smoothingTau }})</span>
1744-
</span>
1745-
<input
1746-
v-model.number="settings.chartFilter.smoothingTau"
1747-
type="range"
1748-
min="0"
1749-
max="26"
1750-
step="1"
1751-
class="accent-[var(--accent-color,var(--fg-subtle))]"
1752-
/>
1753-
</label>
17541726
</div>
17551727
</div>
17561728

app/composables/useSettings.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,7 @@ export interface AppSettings {
3939
animateSparkline: boolean
4040
}
4141
chartFilter: {
42-
hampelWindow: number
43-
hampelThreshold: number
44-
smoothingTau: number
42+
iqrMultiplier: number
4543
}
4644
}
4745

@@ -61,9 +59,7 @@ const DEFAULT_SETTINGS: AppSettings = {
6159
animateSparkline: true,
6260
},
6361
chartFilter: {
64-
hampelWindow: 4,
65-
hampelThreshold: 2,
66-
smoothingTau: 1,
62+
iqrMultiplier: 1.5,
6763
},
6864
}
6965

app/utils/chart-filters.ts

Lines changed: 44 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,75 @@
1+
function median(sorted: number[]): number {
2+
const mid = Math.floor(sorted.length / 2)
3+
return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!
4+
}
5+
16
/**
2-
* Hampel filter: replaces outlier values with the local median.
3-
* Uses Median Absolute Deviation (MAD) to detect outliers.
7+
* IQR-based outlier filter.
8+
*
9+
* 1. Compute Q1, Q3, IQR across all values globally
10+
* 2. Flag any point outside [Q1 - k×IQR, Q3 + k×IQR] as an outlier
11+
* 3. Replace each outlier with the median of its non-outlier local neighbors
12+
*
13+
* This handles sustained anomalies (multi-week spikes) regardless of
14+
* their position in the dataset — including at boundaries.
415
*
516
* @param data - array of objects with a `value` property
6-
* @param windowSize - half-window size (0 = disabled)
7-
* @param threshold - number of MADs above which a value is considered an outlier
8-
* @returns a new array with outlier values replaced by the local median
17+
* @param multiplier - IQR multiplier k (0 = disabled, standard: 1.5, less aggressive: 3)
18+
* @param windowSize - half-window for local median replacement (default: 6)
919
*/
10-
export function hampelFilter<T extends { value: number }>(
20+
export function iqrFilter<T extends { value: number }>(
1121
data: T[],
12-
windowSize: number,
13-
threshold: number,
22+
multiplier: number,
23+
windowSize: number = 6,
1424
): T[] {
15-
if (windowSize <= 0 || data.length === 0) return data
25+
if (multiplier <= 0 || data.length < 4) return data
26+
27+
const sorted = data.map(d => d.value).sort((a, b) => a - b)
28+
const q1 = median(sorted.slice(0, Math.floor(sorted.length / 2)))
29+
const q3 = median(sorted.slice(Math.ceil(sorted.length / 2)))
30+
const iqr = q3 - q1
31+
32+
if (iqr <= 0) return data
33+
34+
const lowerBound = q1 - multiplier * iqr
35+
const upperBound = q3 + multiplier * iqr
36+
37+
const isOutlier = data.map(d => d.value < lowerBound || d.value > upperBound)
38+
if (!isOutlier.some(Boolean)) return data
1639

1740
const result = data.map(d => ({ ...d }))
1841
const n = data.length
1942

20-
for (let i = 0; i < n; i++) {
43+
for (let i = 1; i < n - 1; i++) {
44+
if (!isOutlier[i]) continue
45+
46+
// Collect non-outlier neighbors within the window
2147
const lo = Math.max(0, i - windowSize)
2248
const hi = Math.min(n - 1, i + windowSize)
23-
24-
const windowValues: number[] = []
49+
const neighbors: number[] = []
2550
for (let j = lo; j <= hi; j++) {
26-
windowValues.push(data[j]!.value)
51+
if (!isOutlier[j]) neighbors.push(data[j]!.value)
2752
}
28-
windowValues.sort((a, b) => a - b)
29-
30-
const median = windowValues[Math.floor(windowValues.length / 2)]!
31-
const deviations = windowValues.map(v => Math.abs(v - median)).sort((a, b) => a - b)
32-
const mad = deviations[Math.floor(deviations.length / 2)]!
33-
34-
// 1.4826 converts MAD to an estimate of the standard deviation
35-
const scaledMad = 1.4826 * mad
3653

37-
if (scaledMad > 0 && Math.abs(data[i]!.value - median) > threshold * scaledMad) {
38-
result[i]!.value = median
54+
if (neighbors.length > 0) {
55+
neighbors.sort((a, b) => a - b)
56+
result[i]!.value = median(neighbors)
3957
}
4058
}
4159

4260
return result
4361
}
4462

45-
/**
46-
* Low-pass (exponential smoothing) filter.
47-
*
48-
* @param data - array of objects with a `value` property
49-
* @param tau - smoothing time constant (0 = disabled, higher = smoother)
50-
* @returns a new array with smoothed values
51-
*/
52-
export function lowPassFilter<T extends { value: number }>(data: T[], tau: number): T[] {
53-
if (tau <= 0 || data.length === 0) return data
54-
55-
const result = data.map(d => ({ ...d }))
56-
const alpha = 1 / (1 + tau)
57-
58-
result[0]!.value = data[0]!.value
59-
for (let i = 1; i < data.length; i++) {
60-
result[i]!.value = alpha * data[i]!.value + (1 - alpha) * result[i - 1]!.value
61-
}
62-
63-
return result
64-
}
65-
6663
export interface ChartFilterSettings {
67-
hampelWindow: number
68-
hampelThreshold: number
69-
smoothingTau: number
64+
iqrMultiplier: number
7065
}
7166

7267
/**
73-
* Applies Hampel filter then low-pass smoothing in sequence.
68+
* Applies IQR-based outlier filter to download data.
7469
*/
7570
export function applyDownloadFilter<T extends { value: number }>(
7671
data: T[],
7772
settings: ChartFilterSettings,
7873
): T[] {
79-
if (data.length < 2) return data
80-
81-
const firstValue = data[0]!.value
82-
const lastValue = data[data.length - 1]!.value
83-
84-
let result = data
85-
result = hampelFilter(result, settings.hampelWindow, settings.hampelThreshold)
86-
result = lowPassFilter(result, settings.smoothingTau)
87-
88-
// Preserve original first and last values
89-
if (result !== data) {
90-
result[0]!.value = firstValue
91-
result[result.length - 1]!.value = lastValue
92-
}
93-
94-
return result
74+
return iqrFilter(data, settings.iqrMultiplier)
9575
}

0 commit comments

Comments
 (0)