Skip to content

Commit 275093f

Browse files
Iteration 223: Add stats/nancumops.ts — nan-ignoring aggregate functions
Add 9 top-level nan-ignoring aggregate functions mirroring numpy.nan*: nansum, nanmean, nanmedian, nanvar, nanstd, nanmin, nanmax, nanprod, nancount. All functions accept readonly Scalar[] or Series<Scalar>, skip null/undefined/NaN values, and return a number. nansum/nanprod return identity (0/1) for empty input; all others return NaN for empty input (matches numpy behaviour). - src/stats/nancumops.ts: implementation (NanInput, NanAggOptions types + 9 fns) - tests/stats/nancumops.test.ts: 36 unit tests + 4 property-based tests - playground/nancumops.html: interactive tutorial with live calculator - src/stats/index.ts, src/index.ts: export new symbols Metric: 57 → 58 Run: https://github.com/githubnext/tsessebe/actions/runs/24301503087 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 622e413 commit 275093f

6 files changed

Lines changed: 860 additions & 0 deletions

File tree

playground/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,11 @@ <h3><a href="nunique.html" style="color: var(--accent); text-decoration: none;">
399399
<p>Count unique values (<code>nuniqueSeries</code>, <code>nuniqueDataFrame</code>) and boolean reductions (<code>anySeries</code>, <code>allSeries</code>, <code>anyDataFrame</code>, <code>allDataFrame</code>). Supports <code>dropna</code>, <code>skipna</code>, <code>axis</code>, and <code>boolOnly</code>.</p>
400400
<div class="status done">✅ Complete</div>
401401
</div>
402+
<div class="feature-card">
403+
<h3><a href="nancumops.html" style="color: var(--accent); text-decoration: none;">🔢 NaN-Ignoring Aggregates</a></h3>
404+
<p>Top-level nan-ignoring aggregate functions: <code>nansum</code>, <code>nanmean</code>, <code>nanmedian</code>, <code>nanstd</code>, <code>nanvar</code>, <code>nanmin</code>, <code>nanmax</code>, <code>nanprod</code>, <code>nancount</code>. Mirrors <code>numpy.nan*</code> functions. Works on arrays and Series.</p>
405+
<div class="status done">✅ Complete</div>
406+
</div>
402407
</div>
403408
</section>
404409
</main>

playground/nancumops.html

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>tsb — NaN-Ignoring Aggregates (nancumops)</title>
7+
<style>
8+
:root {
9+
--bg: #0d1117; --surface: #161b22; --border: #30363d;
10+
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
11+
--green: #3fb950; --yellow: #d29922; --red: #f85149;
12+
}
13+
* { box-sizing: border-box; margin: 0; padding: 0; }
14+
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; padding: 2rem; }
15+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
16+
.subtitle { color: var(--muted); margin-bottom: 2rem; }
17+
h2 { font-size: 1.1rem; color: var(--accent); margin: 1.5rem 0 0.5rem; }
18+
p { color: var(--muted); line-height: 1.6; margin-bottom: 0.75rem; }
19+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; }
20+
label { font-size: 0.85rem; color: var(--muted); display: block; margin-bottom: 0.25rem; }
21+
input, select { background: var(--bg); border: 1px solid var(--border); color: var(--text);
22+
border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.95rem; width: 100%; margin-bottom: 0.75rem; }
23+
button { background: var(--accent); color: #0d1117; border: none; border-radius: 4px;
24+
padding: 0.5rem 1.25rem; font-size: 0.95rem; cursor: pointer; font-weight: 600; }
25+
button:hover { opacity: 0.85; }
26+
.output { background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
27+
padding: 0.75rem; font-family: monospace; font-size: 0.9rem; white-space: pre; min-height: 3rem; }
28+
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
29+
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
30+
th { background: var(--bg); color: var(--muted); padding: 0.4rem 0.75rem; text-align: left;
31+
border-bottom: 1px solid var(--border); }
32+
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); font-family: monospace; }
33+
code { background: var(--bg); padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.88rem; }
34+
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.78rem;
35+
font-weight: 600; margin-left: 0.5rem; }
36+
.badge-green { background: #1a3a1c; color: var(--green); }
37+
.badge-blue { background: #112233; color: var(--accent); }
38+
details { margin-bottom: 1rem; }
39+
summary { cursor: pointer; color: var(--accent); font-size: 0.9rem; }
40+
.py-equiv { background: #1a1000; border: 1px solid #3d2800; border-radius: 4px;
41+
padding: 0.75rem; font-family: monospace; font-size: 0.88rem; white-space: pre; color: #d29922; }
42+
</style>
43+
</head>
44+
<body>
45+
46+
<h1>🔢 NaN-Ignoring Aggregates</h1>
47+
<p class="subtitle">
48+
<code>nansum</code>, <code>nanmean</code>, <code>nanmedian</code>, <code>nanstd</code>, <code>nanvar</code>,
49+
<code>nanmin</code>, <code>nanmax</code>, <code>nanprod</code>, <code>nancount</code>
50+
— mirrors <code>numpy.nan*</code> functions in pandas workflows.
51+
</p>
52+
53+
<!-- ── Live Calculator ─────────────────────────────────────────────── -->
54+
<div class="card">
55+
<h2>🧮 Live Calculator</h2>
56+
<p>Enter a comma-separated list of numbers (use <code>NaN</code>, <code>null</code> for missing).</p>
57+
<label>Input values</label>
58+
<input id="inputValues" value="1, 2, NaN, null, 3, 5, NaN" />
59+
<label>ddof (for std/var)</label>
60+
<select id="ddofSelect">
61+
<option value="1">1 (sample — default)</option>
62+
<option value="0">0 (population)</option>
63+
<option value="2">2</option>
64+
</select>
65+
<button onclick="runCalc()">Compute All</button>
66+
<div id="calcOutput" class="output" style="margin-top:0.75rem;"></div>
67+
</div>
68+
69+
<!-- ── Reference Table ────────────────────────────────────────────── -->
70+
<div class="card">
71+
<h2>📖 Function Reference</h2>
72+
<table>
73+
<thead>
74+
<tr>
75+
<th>Function</th>
76+
<th>Description</th>
77+
<th>Empty/all-NaN returns</th>
78+
<th>pandas / numpy equivalent</th>
79+
</tr>
80+
</thead>
81+
<tbody>
82+
<tr><td><code>nancount(input)</code></td><td>Count of valid (non-NaN) numeric values</td><td><code>0</code></td><td><code>np.count_nonzero(~np.isnan(a))</code></td></tr>
83+
<tr><td><code>nansum(input)</code></td><td>Sum, ignoring NaN/null</td><td><code>0</code></td><td><code>np.nansum(a)</code></td></tr>
84+
<tr><td><code>nanmean(input)</code></td><td>Mean, ignoring NaN/null</td><td><code>NaN</code></td><td><code>np.nanmean(a)</code></td></tr>
85+
<tr><td><code>nanmedian(input)</code></td><td>Median, ignoring NaN/null</td><td><code>NaN</code></td><td><code>np.nanmedian(a)</code></td></tr>
86+
<tr><td><code>nanvar(input, {ddof})</code></td><td>Variance (ddof=1 default)</td><td><code>NaN</code></td><td><code>np.nanvar(a, ddof=1)</code></td></tr>
87+
<tr><td><code>nanstd(input, {ddof})</code></td><td>Std deviation (ddof=1 default)</td><td><code>NaN</code></td><td><code>np.nanstd(a, ddof=1)</code></td></tr>
88+
<tr><td><code>nanmin(input)</code></td><td>Minimum, ignoring NaN/null</td><td><code>NaN</code></td><td><code>np.nanmin(a)</code></td></tr>
89+
<tr><td><code>nanmax(input)</code></td><td>Maximum, ignoring NaN/null</td><td><code>NaN</code></td><td><code>np.nanmax(a)</code></td></tr>
90+
<tr><td><code>nanprod(input)</code></td><td>Product, ignoring NaN/null</td><td><code>1</code></td><td><code>np.nanprod(a)</code></td></tr>
91+
</tbody>
92+
</table>
93+
</div>
94+
95+
<!-- ── Usage Examples ─────────────────────────────────────────────── -->
96+
<div class="card">
97+
<h2>💡 Usage Examples</h2>
98+
99+
<details open>
100+
<summary>Basic array usage</summary>
101+
<pre style="margin-top:0.5rem;padding:0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:0.88rem;overflow-x:auto;">
102+
import { nansum, nanmean, nanmedian, nanstd } from "tsb";
103+
104+
const data = [1, 2, NaN, null, 3, 5];
105+
106+
nansum(data); // 11
107+
nanmean(data); // 2.75
108+
nanmedian(data); // 2.5
109+
nanstd(data); // 1.708...
110+
</pre>
111+
<div class="py-equiv">
112+
# Python / pandas equivalent
113+
import numpy as np
114+
115+
data = [1, 2, np.nan, np.nan, 3, 5]
116+
117+
np.nansum(data) # 11.0
118+
np.nanmean(data) # 2.75
119+
np.nanmedian(data) # 2.5
120+
np.nanstd(data, ddof=1) # 1.708...
121+
</div>
122+
</details>
123+
124+
<details>
125+
<summary>Using with Series</summary>
126+
<pre style="margin-top:0.5rem;padding:0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:0.88rem;overflow-x:auto;">
127+
import { Series, nansum, nanmean, nancount } from "tsb";
128+
129+
const s = new Series({ data: [10, null, 30, NaN, 50] });
130+
131+
nancount(s); // 3
132+
nansum(s); // 90
133+
nanmean(s); // 30
134+
</pre>
135+
<div class="py-equiv">
136+
# Python / pandas equivalent
137+
import pandas as pd, numpy as np
138+
139+
s = pd.Series([10, np.nan, 30, np.nan, 50])
140+
141+
s.count() # 3
142+
s.sum() # 90.0
143+
s.mean() # 30.0
144+
</div>
145+
</details>
146+
147+
<details>
148+
<summary>Variance and std with ddof</summary>
149+
<pre style="margin-top:0.5rem;padding:0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:0.88rem;overflow-x:auto;">
150+
import { nanvar, nanstd } from "tsb";
151+
152+
const xs = [2, 4, 4, 4, 5, 5, 7, 9];
153+
154+
// Sample (ddof=1, default)
155+
nanvar(xs); // ≈ 4.571
156+
nanstd(xs); // ≈ 2.138
157+
158+
// Population (ddof=0)
159+
nanvar(xs, { ddof: 0 }); // 4.0
160+
nanstd(xs, { ddof: 0 }); // 2.0
161+
</pre>
162+
<div class="py-equiv">
163+
# Python / pandas equivalent
164+
import numpy as np
165+
166+
xs = [2, 4, 4, 4, 5, 5, 7, 9]
167+
168+
np.nanvar(xs, ddof=1) # 4.571...
169+
np.nanstd(xs, ddof=1) # 2.138...
170+
171+
np.nanvar(xs, ddof=0) # 4.0
172+
np.nanstd(xs, ddof=0) # 2.0
173+
</div>
174+
</details>
175+
</div>
176+
177+
<!-- ── Comparison: skipna vs no-skipna ──────────────────────────────── -->
178+
<div class="card">
179+
<h2>⚡ NaN Impact Demo</h2>
180+
<p>See how NaN values affect results with and without nan-ignoring functions.</p>
181+
<button onclick="runComparison()">Run Comparison</button>
182+
<div id="compOutput" class="output" style="margin-top:0.75rem;min-height:6rem;"></div>
183+
</div>
184+
185+
<script type="module">
186+
// ── inline implementation (mirrors src/stats/nancumops.ts) ──────────
187+
function toValues(input) {
188+
if (Array.isArray(input)) return input;
189+
return input.values ?? [];
190+
}
191+
function numericValues(input) {
192+
return toValues(input).filter(v => typeof v === "number" && !Number.isNaN(v));
193+
}
194+
function sortedAsc(xs) { return [...xs].sort((a, b) => a - b); }
195+
196+
function nancount(input) { return numericValues(input).length; }
197+
function nansum(input) {
198+
const xs = numericValues(input);
199+
return xs.length === 0 ? 0 : xs.reduce((s, x) => s + x, 0);
200+
}
201+
function nanmean(input) {
202+
const xs = numericValues(input);
203+
if (xs.length === 0) return NaN;
204+
return xs.reduce((s, x) => s + x, 0) / xs.length;
205+
}
206+
function nanmedian(input) {
207+
const xs = sortedAsc(numericValues(input));
208+
const n = xs.length;
209+
if (n === 0) return NaN;
210+
const mid = Math.floor(n / 2);
211+
return n % 2 === 1 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2;
212+
}
213+
function nanvar(input, ddof = 1) {
214+
const xs = numericValues(input);
215+
const n = xs.length;
216+
if (n <= ddof) return NaN;
217+
const mean = xs.reduce((s, x) => s + x, 0) / n;
218+
return xs.reduce((ss, x) => ss + (x - mean) ** 2, 0) / (n - ddof);
219+
}
220+
function nanstd(input, ddof = 1) { return Math.sqrt(nanvar(input, ddof)); }
221+
function nanmin(input) {
222+
const xs = numericValues(input);
223+
return xs.length === 0 ? NaN : Math.min(...xs);
224+
}
225+
function nanmax(input) {
226+
const xs = numericValues(input);
227+
return xs.length === 0 ? NaN : Math.max(...xs);
228+
}
229+
function nanprod(input) {
230+
const xs = numericValues(input);
231+
return xs.length === 0 ? 1 : xs.reduce((p, x) => p * x, 1);
232+
}
233+
234+
function parseInput(str) {
235+
return str.split(",").map(s => {
236+
const t = s.trim();
237+
if (t === "null" || t === "undefined") return null;
238+
if (t === "NaN") return NaN;
239+
const n = Number(t);
240+
return Number.isNaN(n) ? null : n;
241+
});
242+
}
243+
244+
function fmt(n) {
245+
if (typeof n !== "number") return String(n);
246+
if (Number.isNaN(n)) return "NaN";
247+
return Number.isInteger(n) ? String(n) : n.toPrecision(6);
248+
}
249+
250+
window.runCalc = function() {
251+
const data = parseInput(document.getElementById("inputValues").value);
252+
const ddof = parseInt(document.getElementById("ddofSelect").value);
253+
const lines = [
254+
`Input (parsed): [${data.map(v => v === null ? "null" : fmt(v)).join(", ")}]`,
255+
`nancount : ${nancount(data)}`,
256+
`nansum : ${fmt(nansum(data))}`,
257+
`nanmean : ${fmt(nanmean(data))}`,
258+
`nanmedian : ${fmt(nanmedian(data))}`,
259+
`nanvar : ${fmt(nanvar(data, ddof))} (ddof=${ddof})`,
260+
`nanstd : ${fmt(nanstd(data, ddof))} (ddof=${ddof})`,
261+
`nanmin : ${fmt(nanmin(data))}`,
262+
`nanmax : ${fmt(nanmax(data))}`,
263+
`nanprod : ${fmt(nanprod(data))}`,
264+
];
265+
document.getElementById("calcOutput").textContent = lines.join("\n");
266+
};
267+
268+
window.runComparison = function() {
269+
const data = [1, 2, NaN, null, 3, 5, NaN, 4];
270+
const cleanData = data.filter(v => typeof v === "number" && !Number.isNaN(v));
271+
272+
const lines = [
273+
`Full array : [${data.map(v => v === null ? "null" : fmt(v)).join(", ")}]`,
274+
`Clean array: [${cleanData.join(", ")}]`,
275+
"",
276+
"Standard JS Math (without NaN handling):",
277+
` [1,2,NaN,...].reduce(+) = ${[1,2,NaN,null,3,5,NaN,4].reduce((a,b) => a + b, 0)}`,
278+
` Math.min(1,2,NaN,null,3,5) = ${Math.min(1,2,NaN,null,3,5)}`,
279+
"",
280+
"tsb nan-ignoring functions:",
281+
` nansum = ${fmt(nansum(data))} (ignores NaN/null)`,
282+
` nanmean = ${fmt(nanmean(data))} (ignores NaN/null)`,
283+
` nanmin = ${fmt(nanmin(data))} (ignores NaN/null)`,
284+
` nanmax = ${fmt(nanmax(data))} (ignores NaN/null)`,
285+
` nancount = ${nancount(data)} / ${data.length} values are valid`,
286+
];
287+
document.getElementById("compOutput").textContent = lines.join("\n");
288+
};
289+
290+
// run on load
291+
window.runCalc();
292+
</script>
293+
294+
</body>
295+
</html>

src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,15 @@ export type {
241241
QuantileSeriesOptions,
242242
QuantileDataFrameOptions,
243243
} from "./stats/index.ts";
244+
export {
245+
nancount,
246+
nansum,
247+
nanmean,
248+
nanmedian,
249+
nanvar,
250+
nanstd,
251+
nanmin,
252+
nanmax,
253+
nanprod,
254+
} from "./stats/index.ts";
255+
export type { NanInput, NanAggOptions } from "./stats/index.ts";

src/stats/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,15 @@ export type {
159159
QuantileSeriesOptions,
160160
QuantileDataFrameOptions,
161161
} from "./quantile.ts";
162+
export {
163+
nancount,
164+
nansum,
165+
nanmean,
166+
nanmedian,
167+
nanvar,
168+
nanstd,
169+
nanmin,
170+
nanmax,
171+
nanprod,
172+
} from "./nancumops.ts";
173+
export type { NanInput, NanAggOptions } from "./nancumops.ts";

0 commit comments

Comments
 (0)