Download javascript/dochia.mjs from danielpetrica/dochia-cycle-model: direct link, hf CLI and curl.
- Browser
- Download file 12.9 kB
-
https://huggingface.co/danielpetrica/dochia-cycle-model/resolve/main/javascript/dochia.mjs
- Command line
-
hf download hf://danielpetrica/dochia-cycle-model/javascript/dochia.mjs
-
curl -L -o dochia.mjs https://huggingface.co/danielpetrica/dochia-cycle-model/resolve/main/javascript/dochia.mjs
12.9 kB
| // Dochia v1 — JavaScript (Node) reference implementation. | |
| // Mirrors python/dochia.py. No dependencies. | |
| const SQRT2 = Math.sqrt(2); | |
| const SQRT2PI = Math.sqrt(2 * Math.PI); | |
| function erf(x) { | |
| // Abramowitz & Stegun 7.1.26 | |
| const sign = x < 0 ? -1 : 1; | |
| const ax = Math.abs(x); | |
| const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741; | |
| const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911; | |
| const t = 1 / (1 + p * ax); | |
| const y = 1 - (((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t) * Math.exp(-ax * ax); | |
| return sign * y; | |
| } | |
| export const normCdf = (z) => 0.5 * (1 + erf(z / SQRT2)); | |
| export const normPdf = (z) => Math.exp(-0.5 * z * z) / SQRT2PI; | |
| export function normPpf(p) { | |
| if (p <= 0) return -Infinity; | |
| if (p >= 1) return Infinity; | |
| let lo = -12, hi = 12; | |
| for (let i = 0; i < 200; i++) { | |
| const mid = 0.5 * (lo + hi); | |
| if (normCdf(mid) < p) lo = mid; else hi = mid; | |
| } | |
| return 0.5 * (lo + hi); | |
| } | |
| export function lognormalParams(mean, sd) { | |
| mean = Math.max(mean, 1e-6); sd = Math.max(sd, 1e-6); | |
| const s2 = Math.log1p((sd * sd) / (mean * mean)); | |
| const s = Math.sqrt(s2); | |
| return { m: Math.log(mean) - 0.5 * s2, s }; | |
| } | |
| export function mixtureFromMoments(mean, sd, pi, tailShift, tailSdMult, momentMatch = false) { | |
| pi = Math.min(Math.max(pi, 0), 0.45); mean = Math.max(mean, 1); sd = Math.max(sd, 0.2); | |
| if (pi <= 1e-9) { | |
| const main = lognormalParams(mean, sd); | |
| return { mean, sd, tailProb: 0, w: [1], m: [main.m], s: [main.s] }; | |
| } | |
| if (!momentMatch) { | |
| const main = lognormalParams(mean, sd); | |
| const tail = lognormalParams(Math.max(mean + tailShift, mean * 1.02), Math.max(sd * tailSdMult, 0.3)); | |
| return { mean, sd, tailProb: pi, w: [1 - pi, pi], m: [main.m, tail.m], s: [main.s, tail.s] }; | |
| } | |
| const tailMean = Math.max(mean + tailShift, mean * 1.02); | |
| const tailSd = Math.max(sd * tailSdMult, 0.3); | |
| const mainMean = (mean - pi * tailMean) / (1 - pi); | |
| const mainVar = ((sd * sd + mean * mean - pi * (tailSd * tailSd + tailMean * tailMean)) / (1 - pi)) - mainMean * mainMean; | |
| if (mainMean <= 0.5 || mainVar <= 0.04) { | |
| const main = lognormalParams(mean, sd); | |
| return { mean, sd, tailProb: 0, w: [1], m: [main.m], s: [main.s] }; | |
| } | |
| const main = lognormalParams(mainMean, Math.sqrt(mainVar)); | |
| const tail = lognormalParams(tailMean, tailSd); | |
| return { mean, sd, tailProb: pi, w: [1 - pi, pi], m: [main.m, tail.m], s: [main.s, tail.s] }; | |
| } | |
| export function mixCdf(mix, x) { | |
| if (x <= 0) return 0; | |
| let total = 0; | |
| for (let i = 0; i < mix.w.length; i++) total += mix.w[i] * normCdf((Math.log(x) - mix.m[i]) / mix.s[i]); | |
| return Math.min(Math.max(total, 0), 1); | |
| } | |
| export function mixPdf(mix, x) { | |
| if (x <= 0) return 0; | |
| let total = 0; | |
| for (let i = 0; i < mix.w.length; i++) { | |
| const z = (Math.log(x) - mix.m[i]) / mix.s[i]; | |
| total += mix.w[i] * normPdf(z) / (x * mix.s[i]); | |
| } | |
| return total; | |
| } | |
| export function mixQuantile(mix, p) { | |
| if (p <= 0) return 0; | |
| if (p >= 1) return 1000; | |
| let lo = 1, hi = 400; | |
| if (mixCdf(mix, lo) >= p) return lo; | |
| if (mixCdf(mix, hi) <= p) return hi; | |
| for (let i = 0; i < 40; i++) { | |
| const mid = 0.5 * (lo + hi); | |
| if (mixCdf(mix, mid) < p) lo = mid; else hi = mid; | |
| } | |
| return 0.5 * (lo + hi); | |
| } | |
| export function calibrated(mix, params) { | |
| return { | |
| mix, | |
| anchorLow: params.tailAnchorLow, | |
| anchorHigh: params.tailAnchorHigh, | |
| scaleLow: Math.max(params.tailScaleLow, 1e-6), | |
| scaleHigh: Math.max(params.tailScaleHigh, 1e-6), | |
| qLow: mixQuantile(mix, params.tailAnchorLow), | |
| qHigh: mixQuantile(mix, params.tailAnchorHigh), | |
| }; | |
| } | |
| export function calQuantile(cal, p) { | |
| if (p <= 0) return 0; | |
| if (p >= 1) return 1000; | |
| if (p <= cal.anchorLow) return cal.qLow + (mixQuantile(cal.mix, p) - cal.qLow) * cal.scaleLow; | |
| if (p >= cal.anchorHigh) return cal.qHigh + (mixQuantile(cal.mix, p) - cal.qHigh) * cal.scaleHigh; | |
| return mixQuantile(cal.mix, p); | |
| } | |
| export function calCdf(cal, x) { | |
| if (x <= cal.qLow) { | |
| const y = cal.qLow + (x - cal.qLow) / cal.scaleLow; | |
| return Math.min(Math.max(mixCdf(cal.mix, y), 0), 1); | |
| } | |
| if (x >= cal.qHigh) { | |
| const y = cal.qHigh + (x - cal.qHigh) / cal.scaleHigh; | |
| return Math.min(Math.max(mixCdf(cal.mix, y), 0), 1); | |
| } | |
| return mixCdf(cal.mix, x); | |
| } | |
| export function calPdf(cal, x) { | |
| if (x <= 0) return 0; | |
| if (x < cal.qLow) return mixPdf(cal.mix, cal.qLow + (x - cal.qLow) / cal.scaleLow) / cal.scaleLow; | |
| if (x > cal.qHigh) return mixPdf(cal.mix, cal.qHigh + (x - cal.qHigh) / cal.scaleHigh) / cal.scaleHigh; | |
| return mixPdf(cal.mix, x); | |
| } | |
| function median(values) { | |
| const s = [...values].sort((a, b) => a - b); | |
| const n = s.length; | |
| if (n === 0) return 0; | |
| return n % 2 === 1 ? s[(n - 1) / 2] : 0.5 * (s[n / 2 - 1] + s[n / 2]); | |
| } | |
| export function personalState(lengthsIn, params) { | |
| const lengths = lengthsIn.filter((y) => y >= params.minLength && y <= params.maxLength).map(Number); | |
| const n = lengths.length; | |
| if (n === 0) { | |
| const withinVar = params.withinSdPrior ** 2; | |
| const between = params.popBetweenSd ** 2; | |
| const posterior = (withinVar * between) / Math.max(1e-9, withinVar); | |
| return { | |
| mean: params.popMean, withinVar, cycleSd: Math.sqrt(withinVar + posterior), n: 0, | |
| tailProb: params.tailProbBase, trend: 0, recentSd: 0, recentRange: 0, | |
| gap: false, abstain: false, confidence: 'new', | |
| }; | |
| } | |
| const mean = lengths.reduce((a, b) => a + b, 0) / n; | |
| let sampleVar = 0; | |
| if (n > 1) sampleVar = lengths.reduce((a, y) => a + (y - mean) ** 2, 0) / (n - 1); | |
| const med = median(lengths); | |
| const mad = median(lengths.map((y) => Math.abs(y - med))); | |
| const robustVar = (1.4826 * mad) ** 2; | |
| sampleVar = params.varianceBlend * sampleVar + (1 - params.varianceBlend) * robustVar; | |
| const priorVar = params.withinSdPrior ** 2; | |
| let priorWeight = params.priorWeight; | |
| if (sampleVar > priorVar) priorWeight = Math.max(0.5, (params.priorWeight * priorVar) / sampleVar); | |
| let withinVar = (n * sampleVar + priorWeight * priorVar) / (n + priorWeight); | |
| withinVar = Math.min(Math.max(withinVar, params.minWithinVar), params.maxWithinVar); | |
| const between = params.popBetweenSd ** 2; | |
| const weight = (n * between) / Math.max(1e-9, n * between + withinVar); | |
| let shrunk = weight * mean + (1 - weight) * params.popMean; | |
| shrunk = Math.min(Math.max(shrunk, params.minMean), params.maxMean); | |
| const recent = lengths.slice(-6); | |
| let trend = 0; | |
| if (n >= 4 && recent.length >= 3) { | |
| const weights = Array.from({ length: recent.length }, (_, k) => (1 - params.trendEwma) ** (recent.length - 1 - k)); | |
| trend = recent.reduce((acc, y, i) => acc + (y - shrunk) * weights[i], 0) / weights.reduce((a, b) => a + b, 0); | |
| const shift = Math.abs(trend) < params.trendThreshold ? 0 : Math.max(-params.trendMaxShift, Math.min(params.trendMaxShift, trend)); | |
| shrunk = Math.min(Math.max(shrunk + shift, params.minMean), params.maxMean); | |
| } | |
| const recentMean = recent.reduce((a, b) => a + b, 0) / recent.length; | |
| const recentSd = recent.length >= 2 | |
| ? Math.sqrt(recent.reduce((a, y) => a + (y - recentMean) ** 2, 0) / (recent.length - 1)) | |
| : 0; | |
| const recentRange = Math.max(...recent) - Math.min(...recent); | |
| const gap = Math.max(...recent) > params.abstainGapDays; | |
| let pi = params.tailProbBase | |
| + params.tailProbSdGain * Math.max(0, recentSd - 2) | |
| + params.tailProbRangeGain * Math.max(0, recentRange - 7); | |
| if (gap) pi += params.tailProbGapBonus; | |
| pi = Math.min(Math.max(pi, params.tailProbBase), params.tailProbMax); | |
| const abstain = gap || recentSd > params.abstainRecentSd || n < params.abstainMinCycles; | |
| const confidence = abstain ? 'abstain' : n >= 6 ? 'established' : n >= 3 ? 'building' : n >= 1 ? 'learning' : 'new'; | |
| const posterior = (withinVar * between) / Math.max(1e-9, n * between + withinVar); | |
| return { | |
| mean: shrunk, withinVar, cycleSd: Math.sqrt(withinVar + posterior), n, tailProb: pi, | |
| trend, recentSd, recentRange, gap, abstain, confidence, | |
| }; | |
| } | |
| export function predictive(state, params) { | |
| const mix = mixtureFromMoments(state.mean, state.cycleSd * params.bodyScale, state.tailProb, | |
| params.tailShiftDays, params.tailSdMultiplier, params.momentMatch); | |
| return calibrated(mix, params); | |
| } | |
| export function hazardCurve(cal, params) { | |
| const curve = new Array(params.hazardMaxDay).fill(0); | |
| let prev = 1; | |
| for (let day = 1; day <= params.hazardMaxDay; day++) { | |
| if (prev <= 1e-12) { curve[day - 1] = 1; continue; } | |
| const f = calCdf(cal, day) - calCdf(cal, day - 1); | |
| curve[day - 1] = Math.min(Math.max(Math.max(0, f) / prev, 0), 1); | |
| prev -= Math.max(0, f); | |
| if (prev < 0) prev = 0; | |
| } | |
| return curve; | |
| } | |
| export function onsetProbs(cal, currentDay, params) { | |
| currentDay = Math.max(1, Math.trunc(currentDay)); | |
| const curve = hazardCurve(cal, params); | |
| const s0 = Math.max(1e-12, 1 - calCdf(cal, currentDay - 1)); | |
| const within = (k) => { | |
| const st = Math.max(0, 1 - calCdf(cal, currentDay - 1 + k)); | |
| return Math.min(Math.max(1 - st / s0, 0), 1); | |
| }; | |
| const curveOut = {}; | |
| for (let d = currentDay; d <= Math.min(params.hazardMaxDay, currentDay + 13); d++) curveOut[d] = curve[d - 1]; | |
| return { | |
| current_day: currentDay, | |
| p_today: currentDay <= curve.length ? curve[currentDay - 1] : 0, | |
| p_within_3: within(3), p_within_7: within(7), p_within_14: within(14), | |
| late_probability: 1 - calCdf(cal, currentDay - 1), | |
| curve: curveOut, | |
| }; | |
| } | |
| export function ovulationDistribution(cal, params) { | |
| const sdO = Math.sqrt(Math.max(cal.mix.sd ** 2 - params.lutealSd ** 2, params.ovulationSdFloor ** 2)); | |
| const meanO = cal.mix.mean - params.lutealMean; | |
| const step = params.ovulationGridStep; | |
| const edges = []; | |
| for (let y = 0; y <= 365 + 1e-9; y += step) edges.push(y); | |
| const cdfEdges = edges.map((e) => calCdf(cal, e)); | |
| const maxDay = params.hazardMaxDay; | |
| const probs = new Array(maxDay).fill(0); | |
| let prev = 0; | |
| for (let day = 1; day <= maxDay; day++) { | |
| let total = 0; | |
| for (let i = 0; i < edges.length - 1; i++) { | |
| const mass = cdfEdges[i + 1] - cdfEdges[i]; | |
| if (mass <= 0) continue; | |
| const yMid = 0.5 * (edges[i] + edges[i + 1]); | |
| total += mass * normCdf((day - yMid + params.lutealMean) / params.lutealSd); | |
| } | |
| probs[day - 1] = Math.min(Math.max(total - prev, 0), 1); | |
| prev = total; | |
| } | |
| probs[maxDay - 1] += Math.max(0, 1 - prev); | |
| const fertile = new Array(maxDay).fill(0); | |
| for (let day = 1; day <= maxDay; day++) { | |
| let sum = 0; | |
| const last = Math.min(maxDay, day + params.fertileWindowDays - 1); | |
| for (let o = day; o <= last; o++) sum += probs[o - 1]; | |
| fertile[day - 1] = Math.min(Math.max(sum, 0), 1); | |
| } | |
| const maxP = Math.max(...fertile); | |
| const peakDays = []; | |
| const windowDays = []; | |
| for (let d = 1; d <= maxDay; d++) { | |
| if (fertile[d - 1] >= Math.max(0.25, 0.5 * maxP)) peakDays.push(d); | |
| if (fertile[d - 1] >= 0.05) windowDays.push(d); | |
| } | |
| return { | |
| mean_day: meanO, sd_day: sdO, | |
| fertile_probabilities: Object.fromEntries(fertile.map((p, i) => [i + 1, p])), | |
| peak_days: peakDays, | |
| window_start: windowDays.length ? Math.min(...windowDays) : null, | |
| window_end: windowDays.length ? Math.max(...windowDays) : null, | |
| }; | |
| } | |
| export function forecast(lengths, currentDay = 1, params = literatureParams()) { | |
| const state = personalState(lengths, params); | |
| const cal = predictive(state, params); | |
| const hazard = onsetProbs(cal, currentDay, params); | |
| const ovulation = ovulationDistribution(cal, params); | |
| const labels = ['0.025', '0.05', '0.1', '0.25', '0.5', '0.75', '0.9', '0.95', '0.975']; | |
| const ps = [0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.975]; | |
| const quantiles = Object.fromEntries(labels.map((label, i) => [label, calQuantile(cal, ps[i])])); | |
| return { | |
| params_version: params.version, | |
| mean_length: state.mean, | |
| sd_length: state.cycleSd * params.bodyScale, | |
| cycles_used: state.n, | |
| confidence: state.confidence, | |
| abstain: state.abstain, | |
| tail_prob: state.tailProb, | |
| trend: state.trend, | |
| quantiles, hazard, ovulation, | |
| }; | |
| } | |
| export function literatureParams() { | |
| return { | |
| version: 'dochia-v1-literature', | |
| popMean: 29.0, popBetweenSd: 3.5, withinSdPrior: 2.0, priorWeight: 4.0, | |
| minMean: 18.0, maxMean: 90.0, minWithinVar: 1.44, maxWithinVar: 36.0, | |
| lutealMean: 14.13, lutealSd: 1.41, fertileWindowDays: 6, | |
| ovulationSdFloor: 0.35, ovulationGridStep: 0.25, | |
| varianceBlend: 1.0, momentMatch: false, | |
| tailProbBase: 0.10, tailProbMax: 0.40, tailProbSdGain: 0.06, | |
| tailProbRangeGain: 0.02, tailProbGapBonus: 0.20, | |
| tailShiftDays: 3.0, tailSdMultiplier: 2.2, | |
| trendEwma: 0.4, trendThreshold: 3.0, trendMaxShift: 3.0, | |
| bodyScale: 1.0, tailScaleLow: 1.0, tailScaleHigh: 1.0, | |
| tailAnchorLow: 0.10, tailAnchorHigh: 0.90, | |
| minLength: 15, maxLength: 120, hazardMaxDay: 120, | |
| abstainGapDays: 90, abstainRecentSd: 6.0, abstainMinCycles: 0, | |
| }; | |
| } | |