Skip to content

Commit 946201b

Browse files
Iteration 148: numeric_extended — digitize, histogram, linspace, arange, zscore, minMaxNormalize, coefficientOfVariation
Added `src/stats/numeric_extended.ts` — 9 standalone numpy/scipy-style numeric utility functions: - `digitize` / `seriesDigitize`: bin values into intervals (mirrors numpy.digitize) - `histogram`: frequency counts with explicit bins, range, and density options (mirrors numpy.histogram) - `linspace`: evenly-spaced number sequence (mirrors numpy.linspace) - `arange`: range with step, 3 overloads (mirrors numpy.arange) - `percentileOfScore`: percentile rank of a score, 4 kinds (mirrors scipy.stats.percentileofscore) - `zscore`: z-score standardisation with ddof option (mirrors scipy.stats.zscore) - `minMaxNormalize`: scale to [0,1] or custom range (mirrors sklearn MinMaxScaler) - `coefficientOfVariation`: std/|mean| (dimensionless spread) 55 unit tests + 4 property-based tests (fast-check). Playground page `numeric_extended.html`. Run: https://github.com/githubnext/tsessebe/actions/runs/24207537328 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c4fd3cd commit 946201b

6 files changed

Lines changed: 1489 additions & 0 deletions

File tree

playground/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,11 @@ <h3><a href="pipe_apply.html" style="color: var(--accent); text-decoration: none
314314
<p>Standalone equivalents of pandas' <code>pipe()</code> / <code>apply()</code> / <code>applymap()</code>: <code>pipe</code> (variadic type-safe pipeline), <code>seriesApply</code> (element-wise with label/pos context), <code>seriesTransform</code>, <code>dataFrameApply</code> (axis 0/1), <code>dataFrameApplyMap</code> (cell-wise), <code>dataFrameTransform</code> (column-wise), <code>dataFrameTransformRows</code> (row-wise).</p>
315315
<div class="status done">✅ Complete</div>
316316
</div>
317+
<div class="feature-card">
318+
<h3><a href="numeric_extended.html" style="color: var(--accent); text-decoration: none;">🔢 numeric_extended — Numeric Utilities</a></h3>
319+
<p>numpy/scipy-style numeric utilities: <code>digitize</code> (bin values), <code>histogram</code> (frequency counts with density option), <code>linspace</code> / <code>arange</code> (number sequences), <code>percentileOfScore</code> (percentile rank of a score), <code>zscore</code> (z-score standardisation), <code>minMaxNormalize</code> (scale to [0,1] or custom range), <code>coefficientOfVariation</code> (std/mean). Series-aware variants included.</p>
320+
<div class="status done">✅ Complete</div>
321+
</div>
317322
</div>
318323
</section>
319324
</main>

playground/numeric_extended.html

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
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 — Numeric Utilities (digitize, histogram, linspace, arange, zscore…)</title>
7+
<style>
8+
body { font-family: system-ui, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1.5rem; line-height: 1.6; color: #1a1a1a; }
9+
h1 { font-size: 1.7rem; }
10+
h2 { font-size: 1.2rem; margin-top: 2rem; color: #2563eb; }
11+
pre { background: #f3f4f6; border-radius: 6px; padding: 1rem; overflow-x: auto; font-size: 0.88rem; }
12+
code { font-family: "Fira Code", "Cascadia Code", monospace; }
13+
.output { background: #ecfdf5; border-left: 3px solid #10b981; padding: 0.75rem 1rem; margin-top: 0.5rem; border-radius: 4px; white-space: pre; font-family: monospace; font-size: 0.87rem; }
14+
.section { border-bottom: 1px solid #e5e7eb; padding-bottom: 1.5rem; margin-bottom: 1.5rem; }
15+
a { color: #2563eb; }
16+
</style>
17+
</head>
18+
<body>
19+
<h1>🔢 Numeric Utilities</h1>
20+
<p>
21+
<a href="index.html">← back to index</a>
22+
</p>
23+
<p>
24+
<code>tsb</code> ships numpy/scipy-style numeric utility functions — all implemented
25+
from scratch with no external dependencies:
26+
<code>digitize</code>, <code>histogram</code>, <code>linspace</code>, <code>arange</code>,
27+
<code>percentileOfScore</code>, <code>zscore</code>, <code>minMaxNormalize</code>,
28+
<code>coefficientOfVariation</code>.
29+
</p>
30+
31+
<div class="section">
32+
<h2>digitize — bin values</h2>
33+
<p>
34+
Map each value to the index of the bin it falls into. Mirrors <code>numpy.digitize</code>.
35+
Indices are 0-based; values below the first edge return <code>-1</code>.
36+
</p>
37+
<pre><code>import { digitize, seriesDigitize, Series } from "tsb";
38+
39+
// Find which [0,33), [33,66), [66,100] bucket each score belongs to
40+
const scores = [15, 45, 70, 33, 100];
41+
const edges = [33, 66, 100];
42+
43+
const bins = digitize(scores, edges);
44+
// → [-1, 1, 2, 0, 2]
45+
// 15 &lt; 33 → bin -1 (below first edge)
46+
// 45 ∈ [33,66) → bin 1
47+
// 70 ∈ [66,100)→ bin 2
48+
// 33 ∈ [33,66) → bin 0 (33 &lt; 66, right=false default)
49+
// 100 = last → bin 2
50+
51+
// Series version — preserves index
52+
const s = new Series({ data: [15, 45, 70], index: ["Alice","Bob","Carol"] });
53+
seriesDigitize(s, [33, 66, 100]);
54+
// Series: Alice→-1, Bob→1, Carol→2</code></pre>
55+
<div class="output" id="out-digitize">Running…</div>
56+
</div>
57+
58+
<div class="section">
59+
<h2>histogram — frequency counts</h2>
60+
<p>Count how many values fall in each bin. Mirrors <code>numpy.histogram</code>.</p>
61+
<pre><code>import { histogram } from "tsb";
62+
63+
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
64+
65+
// Default: 10 equal-width bins
66+
const { counts, binEdges } = histogram(data);
67+
68+
// Custom: 5 bins, density normalised
69+
const { counts: d, binEdges: e } = histogram(data, { bins: 5, density: true });
70+
71+
// Explicit edges
72+
histogram(data, { binEdges: [1, 4, 7, 10] });
73+
// counts: [ 3, 3, 4 ]</code></pre>
74+
<div class="output" id="out-histogram">Running…</div>
75+
</div>
76+
77+
<div class="section">
78+
<h2>linspace &amp; arange — number sequences</h2>
79+
<p>Generate evenly-spaced sequences, mirroring <code>numpy.linspace</code> and <code>numpy.arange</code>.</p>
80+
<pre><code>import { linspace, arange } from "tsb";
81+
82+
// 5 values from 0 to 1 (inclusive)
83+
linspace(0, 1, 5);
84+
// → [0, 0.25, 0.5, 0.75, 1]
85+
86+
// 0..4
87+
arange(5);
88+
// → [0, 1, 2, 3, 4]
89+
90+
// From 2 to 10, step 2
91+
arange(2, 10, 2);
92+
// → [2, 4, 6, 8]
93+
94+
// Descending
95+
arange(5, 0, -1);
96+
// → [5, 4, 3, 2, 1]</code></pre>
97+
<div class="output" id="out-linspace">Running…</div>
98+
</div>
99+
100+
<div class="section">
101+
<h2>percentileOfScore — percentile rank</h2>
102+
<p>
103+
Compute what percentile a given score falls at within a dataset.
104+
Mirrors <code>scipy.stats.percentileofscore</code>.
105+
</p>
106+
<pre><code>import { percentileOfScore } from "tsb";
107+
108+
const grades = [55, 60, 70, 75, 80, 85, 90, 95];
109+
110+
// What percentile is a score of 75?
111+
percentileOfScore(grades, 75); // 50 (rank — default)
112+
percentileOfScore(grades, 75, "weak"); // 50 (≤ 75: 4/8 = 50%)
113+
percentileOfScore(grades, 75, "strict"); // 37.5 (< 75: 3/8 = 37.5%)</code></pre>
114+
<div class="output" id="out-percentile">Running…</div>
115+
</div>
116+
117+
<div class="section">
118+
<h2>zscore — standardisation</h2>
119+
<p>
120+
Transform values to zero mean and unit variance. Mirrors <code>scipy.stats.zscore</code>.
121+
Missing values are propagated; zero-variance data returns all <code>NaN</code>.
122+
</p>
123+
<pre><code>import { zscore, Series } from "tsb";
124+
125+
const s = new Series({ data: [2, 4, 4, 4, 5, 5, 7, 9], name: "values" });
126+
const z = zscore(s);
127+
128+
// z.values ≈ [-1.5, -0.5, -0.5, -0.5, 0, 0, 1, 2]
129+
130+
// With population std (ddof=0)
131+
const zPop = zscore(s, { ddof: 0 });</code></pre>
132+
<div class="output" id="out-zscore">Running…</div>
133+
</div>
134+
135+
<div class="section">
136+
<h2>minMaxNormalize — scale to [0, 1]</h2>
137+
<p>
138+
Scale all values to the interval <code>[0, 1]</code> (or a custom range).
139+
Mirrors <code>sklearn MinMaxScaler</code>.
140+
</p>
141+
<pre><code>import { minMaxNormalize, Series } from "tsb";
142+
143+
const s = new Series({ data: [0, 25, 50, 75, 100] });
144+
minMaxNormalize(s).values;
145+
// → [0, 0.25, 0.5, 0.75, 1]
146+
147+
// Scale to [-1, 1]
148+
minMaxNormalize(s, { featureRangeMin: -1, featureRangeMax: 1 }).values;
149+
// → [-1, -0.5, 0, 0.5, 1]</code></pre>
150+
<div class="output" id="out-minmax">Running…</div>
151+
</div>
152+
153+
<div class="section">
154+
<h2>coefficientOfVariation — relative spread</h2>
155+
<p>
156+
Dimensionless measure of dispersion: <code>std / |mean|</code>.
157+
Useful for comparing spread across datasets with different units.
158+
</p>
159+
<pre><code>import { coefficientOfVariation, Series } from "tsb";
160+
161+
// Dataset A: [10, 20, 30] mean=20, std=10 → CV=0.5
162+
coefficientOfVariation(new Series({ data: [10, 20, 30] }));
163+
164+
// Dataset B: [100, 200, 300] same shape, higher scale → CV=0.5
165+
coefficientOfVariation(new Series({ data: [100, 200, 300] }));
166+
167+
// CV with population std
168+
coefficientOfVariation(new Series({ data: [1, 2, 3, 4, 5] }), { ddof: 0 });</code></pre>
169+
<div class="output" id="out-cv">Running…</div>
170+
</div>
171+
172+
<script type="module">
173+
// ── tiny helpers ──────────────────────────────────────────────────────────
174+
function show(id, lines) {
175+
document.getElementById(id).textContent = lines.join("\n");
176+
}
177+
function fmtArr(arr) {
178+
return "[" + arr.map(v => (typeof v === "number" ? +v.toFixed(4) : v)).join(", ") + "]";
179+
}
180+
181+
// ── inline micro-implementations (no bundler needed) ──────────────────────
182+
// digitize
183+
function digitize(values, bins, right = false) {
184+
return values.map(v => {
185+
if (v === null || (typeof v === "number" && isNaN(v))) return NaN;
186+
const n = bins.length;
187+
if (right) {
188+
for (let i = 0; i < n; i++) if (v <= bins[i]) return i - 1;
189+
return n - 1;
190+
} else {
191+
for (let i = 0; i < n; i++) if (v < bins[i]) return i - 1;
192+
return n - 1;
193+
}
194+
});
195+
}
196+
197+
// histogram
198+
function histogram(values, opts = {}) {
199+
const nums = values.filter(v => typeof v === "number" && !isNaN(v));
200+
const nbins = opts.bins ?? 10;
201+
const lo = Math.min(...nums);
202+
const hi = Math.max(...nums);
203+
const edges = [];
204+
for (let i = 0; i <= nbins; i++) edges.push(lo + (i / nbins) * (hi - lo));
205+
const counts = new Array(nbins).fill(0);
206+
for (const v of nums) {
207+
if (v === hi) { counts[nbins - 1]++; continue; }
208+
let l = 0, r = nbins - 1;
209+
while (l < r) { const m = (l + r) >> 1; if (v < edges[m + 1]) r = m; else l = m + 1; }
210+
counts[l]++;
211+
}
212+
return { counts, binEdges: edges };
213+
}
214+
215+
// linspace
216+
function linspace(start, stop, num = 50) {
217+
if (num === 0) return [];
218+
if (num === 1) return [start];
219+
const result = [];
220+
for (let i = 0; i < num; i++) result.push(i === num - 1 ? stop : start + i * (stop - start) / (num - 1));
221+
return result;
222+
}
223+
224+
// arange
225+
function arange(startOrStop, stop, step = 1) {
226+
let start, s;
227+
if (stop === undefined) { start = 0; s = startOrStop; }
228+
else { start = startOrStop; s = stop; }
229+
const result = [];
230+
if (step > 0) for (let v = start; v < s; v = start + result.length * step) result.push(v);
231+
else for (let v = start; v > s; v = start + result.length * step) result.push(v);
232+
return result;
233+
}
234+
235+
// percentileOfScore
236+
function percentileOfScore(arr, score, kind = "rank") {
237+
const nums = arr.filter(v => typeof v === "number" && !isNaN(v));
238+
const n = nums.length;
239+
if (n === 0) return NaN;
240+
const weak = nums.filter(v => v <= score).length / n * 100;
241+
const strict = nums.filter(v => v < score).length / n * 100;
242+
return kind === "weak" ? weak : kind === "strict" ? strict : (weak + strict) / 2;
243+
}
244+
245+
// zscore
246+
function zscore(arr) {
247+
const nums = arr.filter(v => typeof v === "number" && !isNaN(v));
248+
const n = nums.length;
249+
if (n < 2) return arr.map(() => NaN);
250+
const mean = nums.reduce((a, b) => a + b, 0) / n;
251+
const std = Math.sqrt(nums.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1));
252+
return arr.map(v => (typeof v === "number" && !isNaN(v)) ? (v - mean) / std : v);
253+
}
254+
255+
// minMaxNormalize
256+
function minMaxNormalize(arr, rMin = 0, rMax = 1) {
257+
const nums = arr.filter(v => typeof v === "number" && !isNaN(v));
258+
const lo = Math.min(...nums), hi = Math.max(...nums);
259+
const span = hi - lo;
260+
return arr.map(v => typeof v === "number" && !isNaN(v) ? span === 0 ? (rMin + rMax) / 2 : ((v - lo) / span) * (rMax - rMin) + rMin : v);
261+
}
262+
263+
// CV
264+
function cv(arr) {
265+
const nums = arr.filter(v => typeof v === "number" && !isNaN(v));
266+
const n = nums.length;
267+
const mean = nums.reduce((a, b) => a + b, 0) / n;
268+
const std = Math.sqrt(nums.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1));
269+
return std / Math.abs(mean);
270+
}
271+
272+
// ── demos ─────────────────────────────────────────────────────────────────
273+
try {
274+
const scores = [15, 45, 70, 33, 100];
275+
const edges = [33, 66, 100];
276+
const bins = digitize(scores, edges);
277+
show("out-digitize", [
278+
`scores = ${JSON.stringify(scores)}`,
279+
`edges = ${JSON.stringify(edges)}`,
280+
`bins = ${JSON.stringify(bins)}`,
281+
` 15 < 33 → -1 (below first edge)`,
282+
` 45 ∈ [33,66) → 1`,
283+
` 70 ∈ [66,100)→ 2`,
284+
` 33 ∈ [33,66) → 0`,
285+
` 100 = max → 2`,
286+
]);
287+
} catch (e) { show("out-digitize", ["Error: " + e.message]); }
288+
289+
try {
290+
const data = [1,2,3,4,5,6,7,8,9,10];
291+
const { counts, binEdges } = histogram(data, { bins: 5 });
292+
const explicitCounts = histogram(data, { bins: 3 }).counts;
293+
show("out-histogram", [
294+
`data = [1..10]`,
295+
`5 bins: counts = ${JSON.stringify(counts)}`,
296+
` edges = [${binEdges.map(v => +v.toFixed(2)).join(", ")}]`,
297+
`3 bins: counts = ${JSON.stringify(explicitCounts)}`,
298+
]);
299+
} catch (e) { show("out-histogram", ["Error: " + e.message]); }
300+
301+
try {
302+
show("out-linspace", [
303+
`linspace(0, 1, 5) = ${fmtArr(linspace(0, 1, 5))}`,
304+
`linspace(0, 10, 3) = ${fmtArr(linspace(0, 10, 3))}`,
305+
`arange(5) = ${fmtArr(arange(5))}`,
306+
`arange(2, 10, 2) = ${fmtArr(arange(2, 10, 2))}`,
307+
`arange(5, 0, -1) = ${fmtArr(arange(5, 0, -1))}`,
308+
]);
309+
} catch (e) { show("out-linspace", ["Error: " + e.message]); }
310+
311+
try {
312+
const grades = [55, 60, 70, 75, 80, 85, 90, 95];
313+
show("out-percentile", [
314+
`grades = ${JSON.stringify(grades)}`,
315+
`percentileOfScore(grades, 75) [rank] = ${percentileOfScore(grades, 75)}`,
316+
`percentileOfScore(grades, 75, "weak") = ${percentileOfScore(grades, 75, "weak")}`,
317+
`percentileOfScore(grades, 75, "strict")= ${percentileOfScore(grades, 75, "strict")}`,
318+
]);
319+
} catch (e) { show("out-percentile", ["Error: " + e.message]); }
320+
321+
try {
322+
const data = [2, 4, 4, 4, 5, 5, 7, 9];
323+
const z = zscore(data);
324+
show("out-zscore", [
325+
`data = ${JSON.stringify(data)}`,
326+
`zscore = ${fmtArr(z)}`,
327+
`mean(z) ≈ ${(z.reduce((a, b) => a + b, 0) / z.length).toExponential(2)} (≈ 0)`,
328+
`std(z) ≈ ${+(Math.sqrt(z.reduce((a, b) => a + b ** 2, 0) / (z.length - 1))).toFixed(4)} (≈ 1)`,
329+
]);
330+
} catch (e) { show("out-zscore", ["Error: " + e.message]); }
331+
332+
try {
333+
const data = [0, 25, 50, 75, 100];
334+
const norm01 = minMaxNormalize(data, 0, 1);
335+
const normN1P1 = minMaxNormalize(data, -1, 1);
336+
show("out-minmax", [
337+
`data = ${JSON.stringify(data)}`,
338+
`[0,1] = ${fmtArr(norm01)}`,
339+
`[-1,1] = ${fmtArr(normN1P1)}`,
340+
]);
341+
} catch (e) { show("out-minmax", ["Error: " + e.message]); }
342+
343+
try {
344+
show("out-cv", [
345+
`CV([10,20,30]) = ${+cv([10,20,30]).toFixed(4)} (expect 0.5)`,
346+
`CV([100,200,300]) = ${+cv([100,200,300]).toFixed(4)} (expect 0.5)`,
347+
`CV([1,2,3,4,5]) = ${+cv([1,2,3,4,5]).toFixed(4)}`,
348+
`CV([1,1,1]) std=0 = 0`,
349+
]);
350+
} catch (e) { show("out-cv", ["Error: " + e.message]); }
351+
</script>
352+
</body>
353+
</html>

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,21 @@ export type {
187187
ReplacePair,
188188
IndentOptions,
189189
} from "./stats/index.ts";
190+
export {
191+
digitize,
192+
histogram,
193+
linspace,
194+
arange,
195+
percentileOfScore,
196+
zscore,
197+
minMaxNormalize,
198+
coefficientOfVariation,
199+
seriesDigitize,
200+
} from "./stats/index.ts";
201+
export type {
202+
HistogramOptions,
203+
HistogramResult,
204+
ZscoreOptions,
205+
MinMaxOptions,
206+
CvOptions,
207+
} from "./stats/index.ts";

0 commit comments

Comments
 (0)