diagcalc 3.2.4 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -7
- package/bin/diagcalc.js +142 -41
- package/docs/PROVENANCE.md +17 -0
- package/index.html +407 -92
- package/lib/README.md +98 -0
- package/lib/diagcalc-case.js +148 -0
- package/lib/diagcalc-core.js +554 -89
- package/lib/diagcalc-datasets.js +140 -1
- package/lib/diagcalc-geometry.js +27 -0
- package/lib/diagcalc-i18n.js +255 -0
- package/lib/diagcalc-meta.js +9 -0
- package/lib/diagcalc-presentation.js +278 -0
- package/lib/diagcalc-storage.js +45 -0
- package/lib/diagcalc-types.d.ts +59 -0
- package/package.json +18 -4
- package/script.js +1232 -274
- package/styles.css +2186 -325
- package/tui/index.js +75 -272
- package/tui/input.js +24 -0
- package/tui/presentation.js +168 -0
- package/tui/report.js +130 -0
- package/web/charts.js +339 -0
- package/web/results.js +234 -0
package/lib/diagcalc-core.js
CHANGED
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
(function (root, factory) {
|
|
2
2
|
if (typeof module === "object" && module.exports) {
|
|
3
|
-
module.exports = factory();
|
|
3
|
+
module.exports = factory(require("./diagcalc-presentation"));
|
|
4
4
|
return;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
root.DiagcalcCore = factory();
|
|
8
|
-
}(typeof globalThis !== "undefined" ? globalThis : this, () => {
|
|
9
|
-
|
|
7
|
+
root.DiagcalcCore = factory(root.DiagcalcPresentation);
|
|
8
|
+
}(/** @type {any} */ (typeof globalThis !== "undefined" ? globalThis : this), (presentation) => {
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import("./diagcalc-types").DiagnosticInput} DiagnosticInput
|
|
11
|
+
* @typedef {import("./diagcalc-types").CalculationOptions} CalculationOptions
|
|
12
|
+
*/
|
|
13
|
+
/** @param {unknown} input Confusion matrix counts and probability in percent. */
|
|
14
|
+
function validateInputs(input) {
|
|
15
|
+
if (!input || typeof input !== "object") {
|
|
16
|
+
return { valid: false, message: "Enter a confusion matrix and pre-test probability." };
|
|
17
|
+
}
|
|
18
|
+
const { tp, fp, fn, tn, preTestProb } = /** @type {DiagnosticInput} */ (input);
|
|
10
19
|
const inputs = [tp, fp, fn, tn];
|
|
11
20
|
if (inputs.some((value) => Number.isNaN(value) || value < 0)) {
|
|
12
21
|
return { valid: false, message: "Enter non-negative integers for all confusion matrix cells." };
|
|
13
22
|
}
|
|
14
23
|
|
|
15
|
-
if (inputs.some((value) => !Number.
|
|
24
|
+
if (inputs.some((value) => !Number.isSafeInteger(value))) {
|
|
16
25
|
return { valid: false, message: "Use whole numbers for TP, FP, FN, and TN." };
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
if (Number.
|
|
28
|
+
if (!Number.isSafeInteger(tp + fp + fn + tn)) {
|
|
29
|
+
return { valid: false, message: "The total must be a safe whole number (at most 9007199254740991)." };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!Number.isFinite(preTestProb)) {
|
|
20
33
|
return { valid: false, message: "Enter a valid pre-test probability." };
|
|
21
34
|
}
|
|
22
35
|
|
|
23
|
-
if (preTestProb < 0 || preTestProb
|
|
24
|
-
return { valid: false, message: "Pre-test probability must be
|
|
36
|
+
if (preTestProb < 0 || preTestProb > 100) {
|
|
37
|
+
return { valid: false, message: "Pre-test probability must be between 0% and 100%, inclusive." };
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
if (tp + fn === 0) {
|
|
@@ -32,18 +45,15 @@
|
|
|
32
45
|
return { valid: false, message: "Specificity is indeterminate: add cases without disease (TN or FP)." };
|
|
33
46
|
}
|
|
34
47
|
|
|
35
|
-
if (tp + fp === 0) {
|
|
36
|
-
return { valid: false, message: "PPV is indeterminate: at least one positive test result is required." };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (tn + fn === 0) {
|
|
40
|
-
return { valid: false, message: "NPV is indeterminate: at least one negative test result is required." };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
48
|
return { valid: true };
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
/** @param {DiagnosticInput} input @param {CalculationOptions} [opts] */
|
|
52
|
+
function calculateResults(input, opts) {
|
|
53
|
+
if (!validateInputs(input).valid) return null;
|
|
54
|
+
if (opts && opts.continuityCorrection && !["auto", "always", "never"].includes(opts.continuityCorrection)) return null;
|
|
55
|
+
const { tp, fp, fn, tn, preTestProb } = input;
|
|
56
|
+
const continuityMode = normaliseContinuityMode(opts && opts.continuityCorrection);
|
|
47
57
|
const totals = {
|
|
48
58
|
diseased: tp + fn,
|
|
49
59
|
nonDiseased: tn + fp,
|
|
@@ -56,71 +66,83 @@
|
|
|
56
66
|
const specificity = tn / totals.nonDiseased;
|
|
57
67
|
const ppv = tp / totals.positives;
|
|
58
68
|
const npv = tn / totals.negatives;
|
|
59
|
-
const lrPositive = calculateRatio(sensitivity,
|
|
60
|
-
const lrNegative = calculateRatio(
|
|
69
|
+
const lrPositive = calculateRatio(sensitivity, fp / totals.nonDiseased);
|
|
70
|
+
const lrNegative = calculateRatio(fn / totals.diseased, specificity);
|
|
61
71
|
const preTestOdds = calculateOdds(preTest);
|
|
62
72
|
const postTestOddsPositive = multiplyOdds(preTestOdds, lrPositive);
|
|
63
73
|
const postTestOddsNegative = multiplyOdds(preTestOdds, lrNegative);
|
|
64
74
|
const postTestProbPositive = probabilityFromOdds(postTestOddsPositive);
|
|
65
75
|
const postTestProbNegative = probabilityFromOdds(postTestOddsNegative);
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
const lrPositiveCI = Number.isNaN(lrPositive) ? null : calcLogRatioCI(tp, totals.diseased, fp, totals.nonDiseased, { continuityCorrection: continuityMode });
|
|
78
|
+
const lrNegativeCI = Number.isNaN(lrNegative) ? null : calcLogRatioCI(fn, totals.diseased, tn, totals.nonDiseased, { continuityCorrection: continuityMode });
|
|
79
|
+
const postTestPositiveCI = Number.isNaN(postTestProbPositive) ? null : calcPostTestCI(preTest, lrPositiveCI);
|
|
80
|
+
const postTestNegativeCI = Number.isNaN(postTestProbNegative) ? null : calcPostTestCI(preTest, lrNegativeCI);
|
|
81
|
+
const dor = calcDOR(tp, fp, fn, tn, { continuityCorrection: continuityMode });
|
|
82
|
+
const nns = (sensitivity > 0 && preTest > 0) ? 1 / (sensitivity * preTest) : Infinity;
|
|
83
|
+
|
|
84
|
+
const results = {
|
|
68
85
|
sensitivity: {
|
|
69
|
-
label: "Sensitivity",
|
|
70
86
|
value: sensitivity,
|
|
71
87
|
ci: calcWilsonInterval(tp, totals.diseased),
|
|
72
|
-
note: buildSensitivityNote(sensitivity),
|
|
73
88
|
},
|
|
74
89
|
specificity: {
|
|
75
|
-
label: "Specificity",
|
|
76
90
|
value: specificity,
|
|
77
91
|
ci: calcWilsonInterval(tn, totals.nonDiseased),
|
|
78
|
-
note: buildSpecificityNote(specificity),
|
|
79
92
|
},
|
|
80
93
|
ppv: {
|
|
81
|
-
label: "Positive predictive value (PPV)",
|
|
82
94
|
value: ppv,
|
|
83
95
|
ci: calcWilsonInterval(tp, totals.positives),
|
|
84
|
-
note: "Probability of disease given a positive result.",
|
|
85
96
|
},
|
|
86
97
|
npv: {
|
|
87
|
-
label: "Negative predictive value (NPV)",
|
|
88
98
|
value: npv,
|
|
89
99
|
ci: calcWilsonInterval(tn, totals.negatives),
|
|
90
|
-
|
|
100
|
+
},
|
|
101
|
+
dor: {
|
|
102
|
+
value: dor.value,
|
|
103
|
+
ci: dor.ci,
|
|
104
|
+
},
|
|
105
|
+
numberNeededToScreen: {
|
|
106
|
+
value: nns,
|
|
91
107
|
},
|
|
92
108
|
lrPositive: {
|
|
93
|
-
label: "Positive likelihood ratio (LR+)",
|
|
94
109
|
value: lrPositive,
|
|
95
|
-
|
|
96
|
-
note: interpretLRPositive(lrPositive),
|
|
110
|
+
ci: lrPositiveCI,
|
|
97
111
|
},
|
|
98
112
|
lrNegative: {
|
|
99
|
-
label: "Negative likelihood ratio (LR-)",
|
|
100
113
|
value: lrNegative,
|
|
101
|
-
|
|
102
|
-
note: interpretLRNegative(lrNegative),
|
|
114
|
+
ci: lrNegativeCI,
|
|
103
115
|
},
|
|
104
116
|
preTestProbability: {
|
|
105
|
-
label: "Pre-test probability",
|
|
106
117
|
value: preTest,
|
|
107
|
-
note: "Estimated starting point before the test result.",
|
|
108
118
|
},
|
|
109
119
|
postTestPositive: {
|
|
110
|
-
label: "Post-test probability (positive result)",
|
|
111
120
|
value: postTestProbPositive,
|
|
112
|
-
|
|
121
|
+
ci: postTestPositiveCI,
|
|
113
122
|
},
|
|
114
123
|
postTestNegative: {
|
|
115
|
-
label: "Post-test probability (negative result)",
|
|
116
124
|
value: postTestProbNegative,
|
|
117
|
-
|
|
125
|
+
ci: postTestNegativeCI,
|
|
118
126
|
},
|
|
119
127
|
};
|
|
128
|
+
return Object.fromEntries(Object.entries(results).map(([key, metric]) => [key, {
|
|
129
|
+
...metric,
|
|
130
|
+
unit: ["dor", "lrPositive", "lrNegative"].includes(key) ? "ratio" : key === "numberNeededToScreen" ? "count" : "probability",
|
|
131
|
+
interpretation: { id: key, params: { value: metric.value } },
|
|
132
|
+
}]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Compatibility adapter: new integrations may consume calculateResults()
|
|
136
|
+
// directly; all prose and formatter selection live in presentation.
|
|
137
|
+
/** @param {DiagnosticInput} input @param {CalculationOptions} [opts] */
|
|
138
|
+
function calculateMetrics(input, opts = {}) {
|
|
139
|
+
const results = calculateResults(input, opts);
|
|
140
|
+
return results && presentation.presentResults(results, { formatLikelihood, formatNNS }, opts && opts.locale);
|
|
120
141
|
}
|
|
121
142
|
|
|
143
|
+
/** @param {number} successes @param {number} total */
|
|
122
144
|
function calcWilsonInterval(successes, total) {
|
|
123
|
-
if (total
|
|
145
|
+
if (!Number.isSafeInteger(total) || !Number.isSafeInteger(successes) || successes < 0 || successes > total || total <= 0) {
|
|
124
146
|
return null;
|
|
125
147
|
}
|
|
126
148
|
|
|
@@ -134,7 +156,436 @@
|
|
|
134
156
|
return { lower, upper };
|
|
135
157
|
}
|
|
136
158
|
|
|
159
|
+
// 95% CI for a ratio of two binomial proportions on the log scale.
|
|
160
|
+
// Used for LR+: (x1, n1) = (TP, diseased), (x2, n2) = (FP, non-diseased).
|
|
161
|
+
// Used for LR-: (x1, n1) = (FN, diseased), (x2, n2) = (TN, non-diseased).
|
|
162
|
+
// Simel DL, Samsa GP, Matchar DB. J Clin Epidemiol 1991;44(8):763–770.
|
|
163
|
+
// Continuity correction (+0.5 on each cell) applied when any cell is 0.
|
|
164
|
+
function calcLogRatioCI(x1, n1, x2, n2, opts) {
|
|
165
|
+
if (![x1, n1, x2, n2].every(Number.isSafeInteger) || x1 < 0 || x2 < 0 || n1 <= 0 || n2 <= 0 || x1 > n1 || x2 > n2) return null;
|
|
166
|
+
const mode = normaliseContinuityMode(opts && opts.continuityCorrection);
|
|
167
|
+
let a = x1;
|
|
168
|
+
let b = n1 - x1;
|
|
169
|
+
let c = x2;
|
|
170
|
+
let d = n2 - x2;
|
|
171
|
+
const hasZero = a === 0 || b === 0 || c === 0 || d === 0;
|
|
172
|
+
|
|
173
|
+
if (mode === "always" || (mode === "auto" && hasZero)) {
|
|
174
|
+
a += 0.5;
|
|
175
|
+
b += 0.5;
|
|
176
|
+
c += 0.5;
|
|
177
|
+
d += 0.5;
|
|
178
|
+
}
|
|
179
|
+
// "never" mode: leave cells as-is — the existing checks below catch the
|
|
180
|
+
// cases where the maths actually fails (p1 or p2 = 0).
|
|
181
|
+
|
|
182
|
+
const tn1 = a + b;
|
|
183
|
+
const tn2 = c + d;
|
|
184
|
+
const p1 = a / tn1;
|
|
185
|
+
const p2 = c / tn2;
|
|
186
|
+
|
|
187
|
+
if (p1 === 0 || p2 === 0) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const logRatio = Math.log(p1 / p2);
|
|
192
|
+
const seLog = Math.sqrt((1 - p1) / a + (1 - p2) / c);
|
|
193
|
+
|
|
194
|
+
if (!Number.isFinite(seLog) || !Number.isFinite(logRatio)) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const z = 1.96;
|
|
199
|
+
return {
|
|
200
|
+
lower: Math.exp(logRatio - z * seLog),
|
|
201
|
+
upper: Math.exp(logRatio + z * seLog),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normaliseContinuityMode(value) {
|
|
206
|
+
if (value === "always" || value === "never") return value;
|
|
207
|
+
return "auto";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Cohen's kappa for inter-rater agreement on a binary outcome.
|
|
211
|
+
// Inputs: the four cells of the rater1×rater2 2×2 table.
|
|
212
|
+
// Returns { value, ci, observed, expected, n, interpretation } or null on
|
|
213
|
+
// empty input. Marginal-adjusted asymptotic variance for the CI; Landis & Koch (1977) bins
|
|
214
|
+
// for the qualitative interpretation.
|
|
215
|
+
function calcCohenKappa(input) {
|
|
216
|
+
if (!input || typeof input !== "object") return null;
|
|
217
|
+
const { bothPos, only1Pos, only2Pos, bothNeg } = input;
|
|
218
|
+
const cells = [bothPos, only1Pos, only2Pos, bothNeg];
|
|
219
|
+
if (cells.some((v) => !Number.isSafeInteger(v) || v < 0)) return null;
|
|
220
|
+
const N = bothPos + only1Pos + only2Pos + bothNeg;
|
|
221
|
+
if (N === 0 || !Number.isSafeInteger(N)) return null;
|
|
222
|
+
|
|
223
|
+
const po = (bothPos + bothNeg) / N;
|
|
224
|
+
const r1Pos = (bothPos + only1Pos) / N;
|
|
225
|
+
const r2Pos = (bothPos + only2Pos) / N;
|
|
226
|
+
const r1Neg = (only2Pos + bothNeg) / N;
|
|
227
|
+
const r2Neg = (only1Pos + bothNeg) / N;
|
|
228
|
+
const pe = r1Pos * r2Pos + r1Neg * r2Neg;
|
|
229
|
+
|
|
230
|
+
if (pe >= 1 - 1e-12) {
|
|
231
|
+
return {
|
|
232
|
+
value: NaN,
|
|
233
|
+
ci: null,
|
|
234
|
+
observed: po,
|
|
235
|
+
expected: pe,
|
|
236
|
+
n: N,
|
|
237
|
+
interpretation: "Expected agreement is 1; kappa is undefined.",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const kappa = (po - pe) / (1 - pe);
|
|
242
|
+
// Unweighted asymptotic variance including estimated marginal frequencies.
|
|
243
|
+
// Reference implementation: statsmodels.stats.inter_rater.cohens_kappa.
|
|
244
|
+
const rows = [r1Pos, r1Neg];
|
|
245
|
+
const cols = [r2Pos, r2Neg];
|
|
246
|
+
const diagonal = [bothPos / N, bothNeg / N];
|
|
247
|
+
const termA = diagonal.reduce((sum, p, i) =>
|
|
248
|
+
sum + p * (1 - (rows[i] + cols[i]) * (1 - kappa)) ** 2, 0);
|
|
249
|
+
const termB = (1 - kappa) ** 2 * (
|
|
250
|
+
(only1Pos / N) * (cols[0] + rows[1]) ** 2 +
|
|
251
|
+
(only2Pos / N) * (cols[1] + rows[0]) ** 2
|
|
252
|
+
);
|
|
253
|
+
const termC = (kappa - pe * (1 - kappa)) ** 2;
|
|
254
|
+
const variance = Math.max(0, (termA + termB - termC) / ((1 - pe) ** 2 * N));
|
|
255
|
+
const seKappa = Math.sqrt(variance);
|
|
256
|
+
const z = 1.96;
|
|
257
|
+
return {
|
|
258
|
+
value: kappa,
|
|
259
|
+
ci: { lower: kappa - z * seKappa, upper: kappa + z * seKappa },
|
|
260
|
+
observed: po,
|
|
261
|
+
expected: pe,
|
|
262
|
+
n: N,
|
|
263
|
+
interpretation: interpretKappa(kappa),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function interpretKappa(value) {
|
|
268
|
+
return presentation.noteFor("interpretKappa", value);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ROC reconstruction from a list of (cutoff, TP, FP, FN, TN) rows, one per
|
|
272
|
+
// threshold of a continuous test. Returns the sorted points on the (FPR, TPR)
|
|
273
|
+
// plane, trapezoidal AUC including the (0,0) and (1,1) anchors, and the index
|
|
274
|
+
// of the row with the highest Youden's J = sens + spec − 1.
|
|
275
|
+
/** @param {unknown} rows @param {{direction?: string}} [opts] */
|
|
276
|
+
function validateRocRows(rows, opts = {}) {
|
|
277
|
+
const errors = [];
|
|
278
|
+
if (!Array.isArray(rows) || rows.length === 0 || rows.length > 500) {
|
|
279
|
+
return { valid: false, errors: [{ row: null, message: "Use between one and 500 complete cutoff rows." }] };
|
|
280
|
+
}
|
|
281
|
+
let totals = null;
|
|
282
|
+
rows.forEach((row, index) => {
|
|
283
|
+
const validation = validateInputs(row && { ...row, preTestProb: 50 });
|
|
284
|
+
if (!validation.valid) {
|
|
285
|
+
errors.push({ row: index + 1, message: validation.message });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const current = [row.tp + row.fn, row.fp + row.tn];
|
|
289
|
+
if (totals && (totals[0] !== current[0] || totals[1] !== current[1])) {
|
|
290
|
+
errors.push({ row: index + 1, message: "All cutoffs must use the same diseased and non-diseased cohorts." });
|
|
291
|
+
}
|
|
292
|
+
totals = totals || current;
|
|
293
|
+
if (row.cutoff !== null && row.cutoff !== undefined && !Number.isFinite(row.cutoff)) {
|
|
294
|
+
errors.push({ row: index + 1, message: "Cutoff must be a finite number or left blank." });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
if (errors.length) return { valid: false, errors };
|
|
298
|
+
const sorted = rows.map((row, index) => ({ ...row, index }))
|
|
299
|
+
.sort((a, b) => a.fp - b.fp || a.tp - b.tp);
|
|
300
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
301
|
+
if (sorted[i].tp < sorted[i - 1].tp) {
|
|
302
|
+
errors.push({ row: sorted[i].index + 1, message: "Sensitivity must not decrease as the false-positive rate increases." });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const direction = opts.direction === "lower" ? "lower" : "higher";
|
|
306
|
+
const cutoffs = sorted.filter((row) => Number.isFinite(row.cutoff));
|
|
307
|
+
for (let i = 1; i < cutoffs.length; i += 1) {
|
|
308
|
+
const previous = cutoffs[i - 1];
|
|
309
|
+
const current = cutoffs[i];
|
|
310
|
+
const changed = current.tp !== previous.tp || current.fp !== previous.fp;
|
|
311
|
+
const ordered = direction === "higher" ? current.cutoff < previous.cutoff : current.cutoff > previous.cutoff;
|
|
312
|
+
if (changed && !ordered) {
|
|
313
|
+
errors.push({ row: current.index + 1, message: `Cutoffs must follow the selected ${direction}-score-positive direction.` });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { valid: errors.length === 0, errors };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function calculateROC(rows, opts) {
|
|
320
|
+
if (!validateRocRows(rows, opts).valid) return null;
|
|
321
|
+
const points = rows.map((row) => {
|
|
322
|
+
const sens = row.tp / (row.tp + row.fn);
|
|
323
|
+
const spec = row.tn / (row.tn + row.fp);
|
|
324
|
+
return {
|
|
325
|
+
...row,
|
|
326
|
+
cutoff: row.cutoff ?? null,
|
|
327
|
+
provenance: row.provenance || (row.synthetic ? "simulated" : "observed"),
|
|
328
|
+
sens,
|
|
329
|
+
spec,
|
|
330
|
+
fpr: row.fp / (row.tn + row.fp),
|
|
331
|
+
youden: sens + spec - 1,
|
|
332
|
+
};
|
|
333
|
+
}).sort((a, b) => a.fpr - b.fpr || a.sens - b.sens);
|
|
334
|
+
|
|
335
|
+
const augmented = [
|
|
336
|
+
{ fpr: 0, sens: 0 },
|
|
337
|
+
...points,
|
|
338
|
+
{ fpr: 1, sens: 1 },
|
|
339
|
+
];
|
|
340
|
+
let auc = 0;
|
|
341
|
+
for (let i = 1; i < augmented.length; i += 1) {
|
|
342
|
+
const dx = augmented[i].fpr - augmented[i - 1].fpr;
|
|
343
|
+
if (dx <= 0) continue;
|
|
344
|
+
auc += dx * (augmented[i].sens + augmented[i - 1].sens) / 2;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let optimalIndex = -1;
|
|
348
|
+
let maxYouden = -Infinity;
|
|
349
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
350
|
+
if (points[i].youden > maxYouden) {
|
|
351
|
+
maxYouden = points[i].youden;
|
|
352
|
+
optimalIndex = i;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
points,
|
|
358
|
+
simulated: points.some((point) => point.provenance !== "observed"),
|
|
359
|
+
auc,
|
|
360
|
+
optimalIndex,
|
|
361
|
+
optimalPoint: optimalIndex >= 0 ? points[optimalIndex] : null,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Standard normal CDF Φ(x). Abramowitz & Stegun 7.1.26 polynomial approximation
|
|
366
|
+
// of erf; max error ~1.5e-7 — fine for ROC scaffolding.
|
|
367
|
+
/** @param {number} x */
|
|
368
|
+
function stdNormalCdf(x) {
|
|
369
|
+
if (Number.isNaN(x)) return NaN;
|
|
370
|
+
if (!Number.isFinite(x)) return x > 0 ? 1 : 0;
|
|
371
|
+
const sign = x < 0 ? -1 : 1;
|
|
372
|
+
const ax = Math.abs(x) / Math.SQRT2;
|
|
373
|
+
const t = 1 / (1 + 0.3275911 * ax);
|
|
374
|
+
const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
|
|
375
|
+
return 0.5 * (1 + sign * y);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Inverse standard normal Φ⁻¹(p) via Acklam's rational approximation.
|
|
379
|
+
// Max relative error ~1.15e-9 in (0, 1).
|
|
380
|
+
/** @param {number} p */
|
|
381
|
+
function invStdNormal(p) {
|
|
382
|
+
if (!(p > 0 && p < 1)) {
|
|
383
|
+
if (p === 0) return -Infinity;
|
|
384
|
+
if (p === 1) return Infinity;
|
|
385
|
+
return NaN;
|
|
386
|
+
}
|
|
387
|
+
const a = [-3.969683028665376e+1, 2.209460984245205e+2, -2.759285104469687e+2, 1.383577518672690e+2, -3.066479806614716e+1, 2.506628277459239e+0];
|
|
388
|
+
const b = [-5.447609879822406e+1, 1.615858368580409e+2, -1.556989798598866e+2, 6.680131188771972e+1, -1.328068155288572e+1];
|
|
389
|
+
const c = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838e+0, -2.549732539343734e+0, 4.374664141464968e+0, 2.938163982698783e+0];
|
|
390
|
+
const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996e+0, 3.754408661907416e+0];
|
|
391
|
+
const pLow = 0.02425;
|
|
392
|
+
const pHigh = 1 - pLow;
|
|
393
|
+
let q, r;
|
|
394
|
+
if (p < pLow) {
|
|
395
|
+
q = Math.sqrt(-2 * Math.log(p));
|
|
396
|
+
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
397
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
398
|
+
}
|
|
399
|
+
if (p <= pHigh) {
|
|
400
|
+
q = p - 0.5;
|
|
401
|
+
r = q * q;
|
|
402
|
+
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
|
|
403
|
+
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
|
|
404
|
+
}
|
|
405
|
+
q = Math.sqrt(-2 * Math.log(1 - p));
|
|
406
|
+
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
407
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Synthesize a scaffold of ROC cutoffs from a single observed 2×2.
|
|
411
|
+
// Assumes a binormal (equal-variance) score model: scores in non-diseased
|
|
412
|
+
// ~ N(0,1), in diseased ~ N(d,1). The observed sens/spec pin down d via
|
|
413
|
+
// d = Φ⁻¹(sens) + Φ⁻¹(spec). Then we sweep `count` cutoffs at evenly spaced
|
|
414
|
+
// FPRs in (0, 1), round the implied counts to the observed P/N totals.
|
|
415
|
+
// Returns an array of { cutoff, tp, fp, fn, tn, synthetic: true } — the
|
|
416
|
+
// cutoff is the implied normalized score (Φ⁻¹(1-FPR)) rounded to 2 dp.
|
|
417
|
+
function generateSyntheticRocPoints(observed, count) {
|
|
418
|
+
if (!validateInputs(observed && { ...observed, preTestProb: 50 }).valid) return [];
|
|
419
|
+
const { tp, fp, fn, tn } = observed;
|
|
420
|
+
if (!Number.isInteger(tp) || !Number.isInteger(fp) || !Number.isInteger(fn) || !Number.isInteger(tn)) return [];
|
|
421
|
+
const P = tp + fn;
|
|
422
|
+
const N = tn + fp;
|
|
423
|
+
if (P <= 0 || N <= 0) return [];
|
|
424
|
+
const sens = tp / P;
|
|
425
|
+
const spec = tn / N;
|
|
426
|
+
// Edge: a perfect (or pathological) observed point has Φ⁻¹(0) = -∞.
|
|
427
|
+
// Nudge sens/spec away from 0 and 1 so d stays finite.
|
|
428
|
+
const eps = 1 / (2 * Math.max(P, N));
|
|
429
|
+
const sClamp = Math.min(1 - eps, Math.max(eps, sens));
|
|
430
|
+
const pClamp = Math.min(1 - eps, Math.max(eps, spec));
|
|
431
|
+
const d = invStdNormal(sClamp) + invStdNormal(pClamp);
|
|
432
|
+
const n = Math.max(2, Math.min(20, Number.isInteger(count) ? count : 7));
|
|
433
|
+
// Span (edge, 1 − edge) inclusively so the scaffolded points reach the
|
|
434
|
+
// bottom-left and top-right of the ROC plot. Previous fence-post sampling
|
|
435
|
+
// (i / (n + 1)) topped out around FPR 0.83 and bottomed out around 0.17,
|
|
436
|
+
// leaving the corners empty.
|
|
437
|
+
const edge = 0.025;
|
|
438
|
+
const rows = [];
|
|
439
|
+
for (let i = 0; i < n; i += 1) {
|
|
440
|
+
const fpr = n === 1 ? 0.5 : edge + (1 - 2 * edge) * i / (n - 1);
|
|
441
|
+
const c = invStdNormal(1 - fpr);
|
|
442
|
+
const tprImplied = 1 - stdNormalCdf(c - d);
|
|
443
|
+
const tpI = Math.max(0, Math.min(P, Math.round(tprImplied * P)));
|
|
444
|
+
const fpI = Math.max(0, Math.min(N, Math.round(fpr * N)));
|
|
445
|
+
rows.push({
|
|
446
|
+
cutoff: Number(c.toFixed(2)),
|
|
447
|
+
tp: tpI,
|
|
448
|
+
fp: fpI,
|
|
449
|
+
fn: P - tpI,
|
|
450
|
+
tn: N - fpI,
|
|
451
|
+
synthetic: true,
|
|
452
|
+
provenance: "simulated",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return rows;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Diagnostic odds ratio with log-normal CI.
|
|
459
|
+
// DOR = (TP·TN) / (FP·FN); SE(log DOR) = sqrt(1/TP + 1/FP + 1/FN + 1/TN).
|
|
460
|
+
// Continuity correction (+0.5 to every cell) if any cell is 0.
|
|
461
|
+
function calcDOR(tp, fp, fn, tn, opts) {
|
|
462
|
+
if (![tp, fp, fn, tn].every((value) => Number.isSafeInteger(value) && value >= 0) || !Number.isSafeInteger(tp + fp + fn + tn) || tp + fp + fn + tn === 0) return { value: NaN, ci: null };
|
|
463
|
+
const mode = normaliseContinuityMode(opts && opts.continuityCorrection);
|
|
464
|
+
let a = tp;
|
|
465
|
+
let b = fp;
|
|
466
|
+
let c = fn;
|
|
467
|
+
let d = tn;
|
|
468
|
+
const hasZero = a === 0 || b === 0 || c === 0 || d === 0;
|
|
469
|
+
if (mode === "always" || (mode === "auto" && hasZero)) {
|
|
470
|
+
a += 0.5;
|
|
471
|
+
b += 0.5;
|
|
472
|
+
c += 0.5;
|
|
473
|
+
d += 0.5;
|
|
474
|
+
}
|
|
475
|
+
// never + hasZero → DOR is 0 or ∞ depending on which cell is zero; the CI is undefined.
|
|
476
|
+
const rawValue = mode === "never" ? (tp * tn) / (fp * fn) : (a * d) / (b * c);
|
|
477
|
+
if (!Number.isFinite(rawValue) || rawValue <= 0) {
|
|
478
|
+
return { value: rawValue, ci: null };
|
|
479
|
+
}
|
|
480
|
+
const seLog = Math.sqrt(1 / a + 1 / b + 1 / c + 1 / d);
|
|
481
|
+
const logDor = Math.log(rawValue);
|
|
482
|
+
const z = 1.96;
|
|
483
|
+
return {
|
|
484
|
+
value: rawValue,
|
|
485
|
+
ci: {
|
|
486
|
+
lower: Math.exp(logDor - z * seLog),
|
|
487
|
+
upper: Math.exp(logDor + z * seLog),
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function interpretDOR(value) {
|
|
493
|
+
return presentation.noteFor("interpretDOR", value);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Surface common biases that the 2x2 alone can signal.
|
|
497
|
+
// Returns an array of plain-text warnings; empty array if nothing flagged.
|
|
498
|
+
/** @param {DiagnosticInput} input */
|
|
499
|
+
function buildBiasWarnings({ tp, fp, fn, tn, preTestProb }) {
|
|
500
|
+
const warnings = [];
|
|
501
|
+
const nDiseased = tp + fn;
|
|
502
|
+
const nNonDiseased = tn + fp;
|
|
503
|
+
const total = nDiseased + nNonDiseased;
|
|
504
|
+
|
|
505
|
+
if (nDiseased > 0 && nDiseased < 30) {
|
|
506
|
+
warnings.push(`Small diseased group (n=${nDiseased}). Sensitivity and likelihood-ratio estimates are imprecise — interpret 95% CIs carefully.`);
|
|
507
|
+
}
|
|
508
|
+
if (nNonDiseased > 0 && nNonDiseased < 30) {
|
|
509
|
+
warnings.push(`Small non-diseased group (n=${nNonDiseased}). Specificity and likelihood-ratio estimates are imprecise — interpret 95% CIs carefully.`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (total > 0 && Number.isFinite(preTestProb)) {
|
|
513
|
+
const studyPrev = nDiseased / total;
|
|
514
|
+
const userPrev = preTestProb / 100;
|
|
515
|
+
if (Math.abs(userPrev - studyPrev) > 0.20) {
|
|
516
|
+
warnings.push(
|
|
517
|
+
`Study prevalence (${(studyPrev * 100).toFixed(1)}%) differs substantially from your pre-test probability (${preTestProb.toFixed(1)}%). The PPV and NPV cards reflect the 2x2's prevalence; use the post-test probabilities for your patient.`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return warnings;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Pauker DG, Kassirer JP. The threshold approach to clinical decision making.
|
|
526
|
+
// N Engl J Med 1980;302(20):1109–1117.
|
|
527
|
+
//
|
|
528
|
+
// Given a clinician-stated treatment threshold Pt (the post-test probability
|
|
529
|
+
// at which the expected utility of treating equals that of not treating) and
|
|
530
|
+
// the diagnostic test's LR+ and LR−, return the two pre-test thresholds that
|
|
531
|
+
// bracket the "testing is useful" zone:
|
|
532
|
+
//
|
|
533
|
+
// testingThreshold (P_low): below this pre-test, even a positive
|
|
534
|
+
// result keeps post-test < Pt → don't test
|
|
535
|
+
// testTreatmentThreshold (P_high): above this pre-test, even a negative
|
|
536
|
+
// result keeps post-test ≥ Pt → just treat
|
|
537
|
+
//
|
|
538
|
+
// Derivation: solving post-odds = pre-odds × LR for the pre-test probability
|
|
539
|
+
// that maps to Pt on the post-test scale gives
|
|
540
|
+
// P = Pt / (Pt + (1 − Pt) × LR)
|
|
541
|
+
/** @param {{treatmentThreshold: number, lrPositive: number, lrNegative: number}} opts */
|
|
542
|
+
function calculateThresholds(opts) {
|
|
543
|
+
if (!opts) return null;
|
|
544
|
+
const Pt = opts.treatmentThreshold;
|
|
545
|
+
if (!Number.isFinite(Pt) || Pt <= 0 || Pt >= 1) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (!(opts.lrPositive > 1) || !(opts.lrNegative >= 0 && opts.lrNegative < 1)) return null;
|
|
550
|
+
|
|
551
|
+
function thresholdFromLR(lr) {
|
|
552
|
+
if (Number.isNaN(lr)) return null;
|
|
553
|
+
if (lr === Infinity) return 0;
|
|
554
|
+
if (lr === 0) return 1;
|
|
555
|
+
if (!Number.isFinite(lr) || lr < 0) return null;
|
|
556
|
+
return Pt / (Pt + (1 - Pt) * lr);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
treatmentThreshold: Pt,
|
|
561
|
+
testingThreshold: thresholdFromLR(opts.lrPositive),
|
|
562
|
+
testTreatmentThreshold: thresholdFromLR(opts.lrNegative),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Propagate the LR CI to the post-test probability scale via the Bayes' update
|
|
567
|
+
// Monotonic Bayes transformation preserves endpoints; the prior is fixed.
|
|
568
|
+
function calcPostTestCI(preTestProbability, lrCi) {
|
|
569
|
+
if (!lrCi) {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
if (!Number.isFinite(preTestProbability) || preTestProbability < 0 || preTestProbability > 1) {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const preOdds = calculateOdds(preTestProbability);
|
|
577
|
+
const lower = probabilityFromOdds(multiplyOdds(preOdds, lrCi.lower));
|
|
578
|
+
const upper = probabilityFromOdds(multiplyOdds(preOdds, lrCi.upper));
|
|
579
|
+
|
|
580
|
+
if (Number.isNaN(lower) || Number.isNaN(upper)) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
return { lower, upper };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** @param {number} numerator @param {number} denominator */
|
|
137
587
|
function calculateRatio(numerator, denominator) {
|
|
588
|
+
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || numerator < 0 || denominator < 0) return NaN;
|
|
138
589
|
if (numerator === 0 && denominator === 0) {
|
|
139
590
|
return NaN;
|
|
140
591
|
}
|
|
@@ -147,7 +598,9 @@
|
|
|
147
598
|
return numerator / denominator;
|
|
148
599
|
}
|
|
149
600
|
|
|
601
|
+
/** @param {number} probability */
|
|
150
602
|
function calculateOdds(probability) {
|
|
603
|
+
if (!Number.isFinite(probability) || probability < 0 || probability > 1) return NaN;
|
|
151
604
|
if (probability === 1) {
|
|
152
605
|
return Infinity;
|
|
153
606
|
}
|
|
@@ -157,17 +610,21 @@
|
|
|
157
610
|
return probability / (1 - probability);
|
|
158
611
|
}
|
|
159
612
|
|
|
613
|
+
/** @param {number} odds @param {number} ratio */
|
|
160
614
|
function multiplyOdds(odds, ratio) {
|
|
161
|
-
if (odds
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
if (!Number.isFinite(odds) || !Number.isFinite(ratio)) {
|
|
165
|
-
return Infinity;
|
|
166
|
-
}
|
|
615
|
+
if (typeof odds !== "number" || typeof ratio !== "number" || Number.isNaN(odds) || Number.isNaN(ratio) || odds < 0 || ratio < 0) return NaN;
|
|
616
|
+
if ((odds === 0 && ratio === Infinity) || (ratio === 0 && odds === Infinity)) return NaN;
|
|
167
617
|
return odds * ratio;
|
|
168
618
|
}
|
|
169
619
|
|
|
620
|
+
/** @param {number} probability */
|
|
621
|
+
function chainedPreTestProbability(probability) {
|
|
622
|
+
return Number.isFinite(probability) && probability >= 0 && probability <= 1 ? probability * 100 : NaN;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** @param {number} odds */
|
|
170
626
|
function probabilityFromOdds(odds) {
|
|
627
|
+
if (typeof odds !== "number" || Number.isNaN(odds) || odds < 0) return NaN;
|
|
171
628
|
if (odds === Infinity) {
|
|
172
629
|
return 1;
|
|
173
630
|
}
|
|
@@ -184,6 +641,7 @@
|
|
|
184
641
|
return formatPercentage(value);
|
|
185
642
|
}
|
|
186
643
|
|
|
644
|
+
/** @param {number} value */
|
|
187
645
|
function formatPercentage(value) {
|
|
188
646
|
if (!Number.isFinite(value)) {
|
|
189
647
|
return "—";
|
|
@@ -191,6 +649,7 @@
|
|
|
191
649
|
return `${(value * 100).toFixed(1)}%`;
|
|
192
650
|
}
|
|
193
651
|
|
|
652
|
+
/** @param {unknown} value */
|
|
194
653
|
function normaliseDecimal(value) {
|
|
195
654
|
if (typeof value !== "string") {
|
|
196
655
|
return "";
|
|
@@ -198,6 +657,7 @@
|
|
|
198
657
|
return value.replace(",", ".").trim();
|
|
199
658
|
}
|
|
200
659
|
|
|
660
|
+
/** @param {unknown} value */
|
|
201
661
|
function safeParseInt(value) {
|
|
202
662
|
if (typeof value !== "string") {
|
|
203
663
|
return NaN;
|
|
@@ -209,73 +669,60 @@
|
|
|
209
669
|
if (!/^\d+$/.test(trimmed)) {
|
|
210
670
|
return NaN;
|
|
211
671
|
}
|
|
212
|
-
|
|
672
|
+
const parsed = Number(trimmed);
|
|
673
|
+
return Number.isSafeInteger(parsed) ? parsed : NaN;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** @param {unknown} value */
|
|
677
|
+
function parseProbability(value) {
|
|
678
|
+
const normalised = normaliseDecimal(value);
|
|
679
|
+
if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalised)) return NaN;
|
|
680
|
+
const parsed = Number(normalised);
|
|
681
|
+
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : NaN;
|
|
213
682
|
}
|
|
214
683
|
|
|
684
|
+
/** @param {number} value */
|
|
215
685
|
function formatLikelihood(value) {
|
|
216
|
-
if (
|
|
686
|
+
if (value === Infinity) {
|
|
217
687
|
return "∞";
|
|
218
688
|
}
|
|
689
|
+
if (!Number.isFinite(value)) return "—";
|
|
219
690
|
if (value === 0) {
|
|
220
691
|
return "0";
|
|
221
692
|
}
|
|
222
693
|
return value >= 10 ? value.toFixed(1) : value.toFixed(2);
|
|
223
694
|
}
|
|
224
695
|
|
|
696
|
+
/** @param {number} value */
|
|
697
|
+
function formatNNS(value) {
|
|
698
|
+
if (!Number.isFinite(value)) return "—";
|
|
699
|
+
if (value <= 0) return "—";
|
|
700
|
+
return String(Math.ceil(value));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function interpretNNS(value) {
|
|
704
|
+
return presentation.noteFor("interpretNNS", value);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** @param {number} value @param {number} min @param {number} max */
|
|
225
708
|
function clamp(value, min, max) {
|
|
226
709
|
return Math.min(Math.max(value, min), max);
|
|
227
710
|
}
|
|
228
711
|
|
|
229
712
|
function buildSensitivityNote(value) {
|
|
230
|
-
|
|
231
|
-
return "Captures most cases with disease. Useful for screening.";
|
|
232
|
-
}
|
|
233
|
-
if (value >= 0.7) {
|
|
234
|
-
return "Moderate sensitivity: consider alongside clinical context.";
|
|
235
|
-
}
|
|
236
|
-
return "Low sensitivity: consider additional tests to reduce false negatives.";
|
|
713
|
+
return presentation.noteFor("buildSensitivityNote", value);
|
|
237
714
|
}
|
|
238
715
|
|
|
239
716
|
function buildSpecificityNote(value) {
|
|
240
|
-
|
|
241
|
-
return "Few false positives. Suitable for confirming diagnoses.";
|
|
242
|
-
}
|
|
243
|
-
if (value >= 0.7) {
|
|
244
|
-
return "Moderate specificity: confirm with other laboratory or clinical data.";
|
|
245
|
-
}
|
|
246
|
-
return "Low specificity: beware of false positives and their impact on treatment decisions.";
|
|
717
|
+
return presentation.noteFor("buildSpecificityNote", value);
|
|
247
718
|
}
|
|
248
719
|
|
|
249
720
|
function interpretLRPositive(value) {
|
|
250
|
-
|
|
251
|
-
return "LR+ is very high: a positive result virtually confirms the disease.";
|
|
252
|
-
}
|
|
253
|
-
if (value >= 10) {
|
|
254
|
-
return "LR+ >= 10 indicates strong evidence in favour of disease.";
|
|
255
|
-
}
|
|
256
|
-
if (value >= 5) {
|
|
257
|
-
return "Moderate LR+: substantially increases the probability of disease.";
|
|
258
|
-
}
|
|
259
|
-
if (value >= 2) {
|
|
260
|
-
return "Low LR+: limited gain; combine with other data.";
|
|
261
|
-
}
|
|
262
|
-
return "LR+ close to 1: a positive result barely changes the probability of disease.";
|
|
721
|
+
return presentation.noteFor("interpretLRPositive", value);
|
|
263
722
|
}
|
|
264
723
|
|
|
265
724
|
function interpretLRNegative(value) {
|
|
266
|
-
|
|
267
|
-
return "LR- is infinite: a negative result does not reduce the probability of disease.";
|
|
268
|
-
}
|
|
269
|
-
if (value <= 0.1) {
|
|
270
|
-
return "LR- <= 0.1 indicates strong evidence against disease.";
|
|
271
|
-
}
|
|
272
|
-
if (value <= 0.2) {
|
|
273
|
-
return "Moderate LR-: reduces the probability of disease.";
|
|
274
|
-
}
|
|
275
|
-
if (value <= 0.5) {
|
|
276
|
-
return "Low LR-: limited impact; consider further evaluation.";
|
|
277
|
-
}
|
|
278
|
-
return "LR- close to 1: a negative result does not rule out disease.";
|
|
725
|
+
return presentation.noteFor("interpretLRNegative", value);
|
|
279
726
|
}
|
|
280
727
|
|
|
281
728
|
function buildProbabilityBar(value, width) {
|
|
@@ -286,23 +733,41 @@
|
|
|
286
733
|
}
|
|
287
734
|
|
|
288
735
|
return {
|
|
736
|
+
buildBiasWarnings,
|
|
289
737
|
buildProbabilityBar,
|
|
290
738
|
buildSensitivityNote,
|
|
291
739
|
buildSpecificityNote,
|
|
740
|
+
calcCohenKappa,
|
|
741
|
+
calcDOR,
|
|
742
|
+
calcLogRatioCI,
|
|
743
|
+
calcPostTestCI,
|
|
292
744
|
calcWilsonInterval,
|
|
293
745
|
calculateMetrics,
|
|
746
|
+
calculateResults,
|
|
747
|
+
chainedPreTestProbability,
|
|
294
748
|
calculateOdds,
|
|
749
|
+
calculateROC,
|
|
295
750
|
calculateRatio,
|
|
751
|
+
calculateThresholds,
|
|
296
752
|
clamp,
|
|
297
753
|
formatLikelihood,
|
|
754
|
+
formatNNS,
|
|
298
755
|
formatPercentage,
|
|
299
756
|
formatValue,
|
|
757
|
+
generateSyntheticRocPoints,
|
|
758
|
+
interpretDOR,
|
|
759
|
+
interpretKappa,
|
|
300
760
|
interpretLRNegative,
|
|
301
761
|
interpretLRPositive,
|
|
762
|
+
interpretNNS,
|
|
763
|
+
invStdNormal,
|
|
302
764
|
multiplyOdds,
|
|
303
765
|
normaliseDecimal,
|
|
304
766
|
probabilityFromOdds,
|
|
305
767
|
safeParseInt,
|
|
768
|
+
stdNormalCdf,
|
|
306
769
|
validateInputs,
|
|
770
|
+
validateRocRows,
|
|
771
|
+
parseProbability,
|
|
307
772
|
};
|
|
308
773
|
}));
|