Skip to content

Commit d84b535

Browse files
Iteration 140: Add rolling extended stats (sem, skew, kurt, quantile)
Run: https://github.com/githubnext/tsessebe/actions/runs/24173724265 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f9a2a91 commit d84b535

6 files changed

Lines changed: 999 additions & 0 deletions

File tree

playground/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,11 @@ <h3><a href="cut_qcut.html" style="color: var(--accent); text-decoration: none;"
274274
<p>Bin continuous numeric data into discrete intervals. <code>cut()</code> uses fixed-width or explicit bin edges; <code>qcut()</code> uses quantile-based bins of equal population. Both return codes, labels, and bin edges. Mirrors <code>pandas.cut</code> and <code>pandas.qcut</code>.</p>
275275
<div class="status done">✅ Complete</div>
276276
</div>
277+
<div class="feature-card">
278+
<h3><a href="window_extended.html" style="color: var(--accent); text-decoration: none;">📊 Rolling Extended Stats</a></h3>
279+
<p>Higher-order rolling window statistics: <code>rollingSem</code> (standard error of mean), <code>rollingSkew</code> (Fisher-Pearson skewness), <code>rollingKurt</code> (excess kurtosis), and <code>rollingQuantile</code> (arbitrary percentile with 5 interpolation methods). Mirrors <code>pandas.Series.rolling().sem/skew/kurt/quantile()</code>.</p>
280+
<div class="status done">✅ Complete</div>
281+
</div>
277282
</div>
278283
</section>
279284
</main>

playground/window_extended.html

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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 — Rolling Extended Stats: sem, skew, kurt, quantile</title>
7+
<style>
8+
body {
9+
font-family: system-ui, sans-serif;
10+
max-width: 860px;
11+
margin: 2rem auto;
12+
padding: 0 1rem;
13+
line-height: 1.6;
14+
color: #1a1a1a;
15+
}
16+
h1 { color: #0d47a1; }
17+
h2 { color: #1565c0; border-bottom: 2px solid #e3f2fd; padding-bottom: 0.25rem; }
18+
pre {
19+
background: #f5f5f5;
20+
border-left: 4px solid #0d47a1;
21+
padding: 1rem;
22+
overflow-x: auto;
23+
border-radius: 4px;
24+
}
25+
code { font-family: "Fira Code", "Cascadia Code", monospace; font-size: 0.9em; }
26+
.demo {
27+
background: #e8f5e9;
28+
border: 1px solid #a5d6a7;
29+
border-radius: 6px;
30+
padding: 1rem 1.25rem;
31+
margin: 1rem 0;
32+
}
33+
.demo h3 { margin-top: 0; color: #2e7d32; }
34+
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
35+
th, td { border: 1px solid #ccc; padding: 0.4rem 0.75rem; text-align: left; }
36+
th { background: #e3f2fd; }
37+
.note { background: #fff9c4; border: 1px solid #f9a825; border-radius: 4px; padding: 0.75rem 1rem; }
38+
a { color: #0d47a1; }
39+
</style>
40+
</head>
41+
<body>
42+
<h1>tsb — Rolling Extended Statistics</h1>
43+
<p>
44+
Higher-order rolling window statistics extending the core
45+
<a href="https://pandas.pydata.org/docs/reference/api/pandas.core.window.rolling.Rolling.html">
46+
<code>pandas.Series.rolling()</code>
47+
</a>
48+
API:
49+
<strong>sem</strong>, <strong>skew</strong>, <strong>kurt</strong>, and
50+
<strong>quantile</strong>.
51+
</p>
52+
53+
<h2>1. <code>rollingSem</code> — Standard Error of the Mean</h2>
54+
<p>
55+
The <em>standard error of the mean</em> measures how much the sample mean
56+
would vary across repeated samples. For a window of <em>n</em> values:
57+
</p>
58+
<pre><code>sem = std(ddof=1) / √n</code></pre>
59+
<p>Requires at least 2 valid observations per window.</p>
60+
61+
<pre><code>import { rollingSem, Series } from "tsb";
62+
63+
const s = new Series({ data: [2, 4, 4, 4, 5, 5, 7, 9], name: "x" });
64+
const sem3 = rollingSem(s, 3);
65+
// [null, null, 0.667, 0, 0.577, 0.577, 1.155, 2.082]
66+
</code></pre>
67+
68+
<div class="demo">
69+
<h3>Live demo — sem with window=3</h3>
70+
<p>Comma-separated numbers (nulls accepted):</p>
71+
<input id="sem-input" type="text" value="2, 4, 4, 4, 5, 5, 7, 9" style="width:100%">
72+
<label>Window: <input id="sem-win" type="number" value="3" min="2" max="20" style="width:60px"></label>
73+
<label>minPeriods: <input id="sem-mp" type="number" value="" min="1" max="20" placeholder="= window" style="width:80px"></label>
74+
<button onclick="runSem()">Run</button>
75+
<pre id="sem-out" style="margin-top:0.5rem"></pre>
76+
</div>
77+
78+
<h2>2. <code>rollingSkew</code> — Fisher-Pearson Skewness</h2>
79+
<p>
80+
Skewness measures asymmetry of the distribution in each window.
81+
Positive = right tail heavier; negative = left tail heavier.
82+
Uses the unbiased Fisher-Pearson formula (same as pandas):
83+
</p>
84+
<pre><code>skew = [n/((n-1)(n-2))] × Σ[(xᵢ−x̄)/s]³</code></pre>
85+
<p>Requires ≥ 3 valid observations.</p>
86+
87+
<pre><code>import { rollingSkew, Series } from "tsb";
88+
89+
const s = new Series({ data: [1, 2, 3, 4, 5] });
90+
rollingSkew(s, 3);
91+
// [null, null, 0, 0, 0] ← symmetric windows → zero skew
92+
</code></pre>
93+
94+
<div class="demo">
95+
<h3>Live demo — skewness with window=4</h3>
96+
<input id="skew-input" type="text" value="1, 2, 3, 10, 1, 2, 3" style="width:100%">
97+
<label>Window: <input id="skew-win" type="number" value="4" min="3" max="20" style="width:60px"></label>
98+
<button onclick="runSkew()">Run</button>
99+
<pre id="skew-out" style="margin-top:0.5rem"></pre>
100+
</div>
101+
102+
<h2>3. <code>rollingKurt</code> — Excess Kurtosis</h2>
103+
<p>
104+
Kurtosis measures how heavy the tails are relative to a normal distribution.
105+
The <em>excess</em> kurtosis subtracts 3, so a normal distribution gives 0.
106+
Uses the Fisher (1930) unbiased formula:
107+
</p>
108+
<pre><code>kurt = [n(n+1)/((n-1)(n-2)(n-3))] × Σ[(xᵢ−x̄)/s]⁴ − 3(n-1)²/((n-2)(n-3))</code></pre>
109+
<p>Requires ≥ 4 valid observations.</p>
110+
111+
<pre><code>import { rollingKurt, Series } from "tsb";
112+
113+
const s = new Series({ data: [1, 2, 3, 4] });
114+
rollingKurt(s, 4);
115+
// [null, null, null, -1.2] ← uniform distribution has kurt = -1.2
116+
</code></pre>
117+
118+
<div class="demo">
119+
<h3>Live demo — excess kurtosis with window=5</h3>
120+
<input id="kurt-input" type="text" value="1, 2, 3, 4, 5, 6, 7, 8, 9, 10" style="width:100%">
121+
<label>Window: <input id="kurt-win" type="number" value="5" min="4" max="20" style="width:60px"></label>
122+
<button onclick="runKurt()">Run</button>
123+
<pre id="kurt-out" style="margin-top:0.5rem"></pre>
124+
</div>
125+
126+
<h2>4. <code>rollingQuantile</code> — Rolling Quantile</h2>
127+
<p>
128+
Computes any quantile within each sliding window using configurable
129+
interpolation. When <code>q = 0.5</code> this is identical to
130+
<code>rolling.median()</code>.
131+
</p>
132+
133+
<pre><code>import { rollingQuantile, Series } from "tsb";
134+
135+
const s = new Series({ data: [1, 2, 3, 4, 5] });
136+
137+
rollingQuantile(s, 0.5, 3); // rolling median: [null, null, 2, 3, 4]
138+
rollingQuantile(s, 0.25, 3); // [null, null, 1.5, 2.5, 3.5]
139+
rollingQuantile(s, 0.75, 3); // [null, null, 2.5, 3.5, 4.5]
140+
</code></pre>
141+
142+
<h3>Interpolation methods</h3>
143+
<table>
144+
<thead><tr><th>Method</th><th>Behaviour when q falls between two values</th></tr></thead>
145+
<tbody>
146+
<tr><td><code>linear</code> (default)</td><td>Linear interpolation — same as NumPy / pandas default</td></tr>
147+
<tr><td><code>lower</code></td><td>Take the lower of the two surrounding values</td></tr>
148+
<tr><td><code>higher</code></td><td>Take the higher of the two surrounding values</td></tr>
149+
<tr><td><code>midpoint</code></td><td>Arithmetic mean of the two surrounding values</td></tr>
150+
<tr><td><code>nearest</code></td><td>Whichever surrounding value is closest</td></tr>
151+
</tbody>
152+
</table>
153+
154+
<div class="demo">
155+
<h3>Live demo — rolling quantile</h3>
156+
<input id="quant-input" type="text" value="3, 1, 4, 1, 5, 9, 2, 6" style="width:100%">
157+
<label>q (0–1): <input id="quant-q" type="number" value="0.5" min="0" max="1" step="0.05" style="width:70px"></label>
158+
<label>Window: <input id="quant-win" type="number" value="3" min="1" max="20" style="width:60px"></label>
159+
<label>Interpolation:
160+
<select id="quant-interp">
161+
<option value="linear" selected>linear</option>
162+
<option value="lower">lower</option>
163+
<option value="higher">higher</option>
164+
<option value="midpoint">midpoint</option>
165+
<option value="nearest">nearest</option>
166+
</select>
167+
</label>
168+
<button onclick="runQuantile()">Run</button>
169+
<pre id="quant-out" style="margin-top:0.5rem"></pre>
170+
</div>
171+
172+
<h2>Common Options</h2>
173+
<table>
174+
<thead><tr><th>Option</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
175+
<tbody>
176+
<tr><td><code>minPeriods</code></td><td><code>number</code></td><td>= window</td><td>Minimum valid obs required per window</td></tr>
177+
<tr><td><code>center</code></td><td><code>boolean</code></td><td><code>false</code></td><td>Centre the window around each position</td></tr>
178+
</tbody>
179+
</table>
180+
181+
<div class="note">
182+
<strong>Note:</strong> Functions are <em>pure</em> — they return new Series objects
183+
without modifying the input. Missing values (<code>null</code>, <code>NaN</code>)
184+
are excluded from each window calculation.
185+
</div>
186+
187+
<script type="module">
188+
import { Series } from "https://esm.sh/tsb@0.0.1";
189+
190+
function parseData(str) {
191+
return str.split(",").map(v => {
192+
const t = v.trim();
193+
if (t === "" || t === "null" || t === "NaN") return null;
194+
const n = parseFloat(t);
195+
return isNaN(n) ? null : n;
196+
});
197+
}
198+
199+
function fmt(v) {
200+
if (v === null || v === undefined) return "null";
201+
if (typeof v === "number") return v.toFixed(4);
202+
return String(v);
203+
}
204+
205+
// Minimal rolling implementations for the playground (no bundler)
206+
function isMissing(v) { return v === null || v === undefined || (typeof v === "number" && isNaN(v)); }
207+
function validNums(arr) { return arr.filter(v => !isMissing(v) && typeof v === "number"); }
208+
209+
function windowSlice(data, i, window, center) {
210+
if (center) {
211+
const half = Math.floor((window - 1) / 2);
212+
const lo = Math.max(0, i - half);
213+
const hi = Math.min(data.length, i + (window - half));
214+
return data.slice(lo, hi);
215+
}
216+
return data.slice(Math.max(0, i - window + 1), i + 1);
217+
}
218+
219+
function rollingApply(data, window, minPeriods, center, minN, fn) {
220+
const mp = minPeriods ?? window;
221+
return data.map((_, i) => {
222+
const nums = validNums(windowSlice(data, i, window, center));
223+
if (nums.length < Math.max(minN, mp)) return null;
224+
return fn(nums);
225+
});
226+
}
227+
228+
function computeSem(nums) {
229+
const n = nums.length;
230+
const m = nums.reduce((a, v) => a + v, 0) / n;
231+
const s = Math.sqrt(nums.reduce((a, v) => a + (v - m) ** 2, 0) / (n - 1));
232+
return s / Math.sqrt(n);
233+
}
234+
235+
function computeSkew(nums) {
236+
const n = nums.length;
237+
const m = nums.reduce((a, v) => a + v, 0) / n;
238+
const s = Math.sqrt(nums.reduce((a, v) => a + (v - m) ** 2, 0) / (n - 1));
239+
if (s === 0) return 0;
240+
const sum3 = nums.reduce((a, v) => a + ((v - m) / s) ** 3, 0);
241+
return (n / ((n - 1) * (n - 2))) * sum3;
242+
}
243+
244+
function computeKurt(nums) {
245+
const n = nums.length;
246+
const m = nums.reduce((a, v) => a + v, 0) / n;
247+
const s = Math.sqrt(nums.reduce((a, v) => a + (v - m) ** 2, 0) / (n - 1));
248+
if (s === 0) return 0;
249+
const sum4 = nums.reduce((a, v) => a + ((v - m) / s) ** 4, 0);
250+
return (n * (n + 1)) / ((n - 1) * (n - 2) * (n - 3)) * sum4 - 3 * (n - 1) ** 2 / ((n - 2) * (n - 3));
251+
}
252+
253+
function computeQuantile(sorted, q, method) {
254+
const n = sorted.length;
255+
if (n === 1) return sorted[0];
256+
const virt = q * (n - 1);
257+
const lo = Math.floor(virt), hi = Math.ceil(virt);
258+
const lv = sorted[lo], hv = sorted[hi];
259+
if (method === "lower") return lv;
260+
if (method === "higher") return hv;
261+
if (method === "midpoint") return (lv + hv) / 2;
262+
if (method === "nearest") return (virt - lo < 0.5) ? lv : hv;
263+
return lv + (virt - lo) * (hv - lv);
264+
}
265+
266+
window.runSem = function() {
267+
const data = parseData(document.getElementById("sem-input").value);
268+
const win = +document.getElementById("sem-win").value;
269+
const mp = document.getElementById("sem-mp").value ? +document.getElementById("sem-mp").value : undefined;
270+
const result = rollingApply(data, win, mp, false, 2, computeSem);
271+
document.getElementById("sem-out").textContent = "[" + result.map(fmt).join(", ") + "]";
272+
};
273+
274+
window.runSkew = function() {
275+
const data = parseData(document.getElementById("skew-input").value);
276+
const win = +document.getElementById("skew-win").value;
277+
const result = rollingApply(data, win, win, false, 3, computeSkew);
278+
document.getElementById("skew-out").textContent = "[" + result.map(fmt).join(", ") + "]";
279+
};
280+
281+
window.runKurt = function() {
282+
const data = parseData(document.getElementById("kurt-input").value);
283+
const win = +document.getElementById("kurt-win").value;
284+
const result = rollingApply(data, win, win, false, 4, computeKurt);
285+
document.getElementById("kurt-out").textContent = "[" + result.map(fmt).join(", ") + "]";
286+
};
287+
288+
window.runQuantile = function() {
289+
const data = parseData(document.getElementById("quant-input").value);
290+
const q = +document.getElementById("quant-q").value;
291+
const win = +document.getElementById("quant-win").value;
292+
const interp = document.getElementById("quant-interp").value;
293+
const result = rollingApply(data, win, win, false, 1, nums => {
294+
const sorted = [...nums].sort((a, b) => a - b);
295+
return computeQuantile(sorted, q, interp);
296+
});
297+
document.getElementById("quant-out").textContent = "[" + result.map(fmt).join(", ") + "]";
298+
};
299+
300+
// auto-run demos on load
301+
window.runSem(); window.runSkew(); window.runKurt(); window.runQuantile();
302+
</script>
303+
</body>
304+
</html>

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,5 @@ export { wideToLong } from "./reshape/index.ts";
116116
export type { WideToLongOptions } from "./reshape/index.ts";
117117
export { cut, qcut } from "./stats/index.ts";
118118
export type { BinResult, CutOptions, QCutOptions } from "./stats/index.ts";
119+
export { rollingSem, rollingSkew, rollingKurt, rollingQuantile } from "./stats/index.ts";
120+
export type { WindowExtOptions, RollingQuantileOptions } from "./stats/index.ts";

src/stats/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@ export {
4141
export type { NKeep, NTopOptions, NTopDataFrameOptions } from "./nlargest.ts";
4242
export { cut, qcut } from "./cut_qcut.ts";
4343
export type { BinResult, CutOptions, QCutOptions } from "./cut_qcut.ts";
44+
export { rollingSem, rollingSkew, rollingKurt, rollingQuantile } from "./window_extended.ts";
45+
export type { WindowExtOptions, RollingQuantileOptions } from "./window_extended.ts";

0 commit comments

Comments
 (0)