|
| 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> |
0 commit comments