diagcalc 3.2.3 → 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 +60 -6
- package/bin/diagcalc.js +147 -41
- package/docs/PROVENANCE.md +17 -0
- package/index.html +408 -92
- package/lib/README.md +98 -0
- package/lib/diagcalc-case.js +148 -0
- package/lib/diagcalc-core.js +557 -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 +1239 -275
- package/styles.css +2186 -325
- package/tui/index.js +83 -273
- 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 between 0 and
|
|
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,439 @@
|
|
|
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;
|
|
589
|
+
if (numerator === 0 && denominator === 0) {
|
|
590
|
+
return NaN;
|
|
591
|
+
}
|
|
138
592
|
if (denominator === 0) {
|
|
139
593
|
return Infinity;
|
|
140
594
|
}
|
|
@@ -144,7 +598,9 @@
|
|
|
144
598
|
return numerator / denominator;
|
|
145
599
|
}
|
|
146
600
|
|
|
601
|
+
/** @param {number} probability */
|
|
147
602
|
function calculateOdds(probability) {
|
|
603
|
+
if (!Number.isFinite(probability) || probability < 0 || probability > 1) return NaN;
|
|
148
604
|
if (probability === 1) {
|
|
149
605
|
return Infinity;
|
|
150
606
|
}
|
|
@@ -154,17 +610,21 @@
|
|
|
154
610
|
return probability / (1 - probability);
|
|
155
611
|
}
|
|
156
612
|
|
|
613
|
+
/** @param {number} odds @param {number} ratio */
|
|
157
614
|
function multiplyOdds(odds, ratio) {
|
|
158
|
-
if (odds
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
if (!Number.isFinite(odds) || !Number.isFinite(ratio)) {
|
|
162
|
-
return Infinity;
|
|
163
|
-
}
|
|
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;
|
|
164
617
|
return odds * ratio;
|
|
165
618
|
}
|
|
166
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 */
|
|
167
626
|
function probabilityFromOdds(odds) {
|
|
627
|
+
if (typeof odds !== "number" || Number.isNaN(odds) || odds < 0) return NaN;
|
|
168
628
|
if (odds === Infinity) {
|
|
169
629
|
return 1;
|
|
170
630
|
}
|
|
@@ -181,6 +641,7 @@
|
|
|
181
641
|
return formatPercentage(value);
|
|
182
642
|
}
|
|
183
643
|
|
|
644
|
+
/** @param {number} value */
|
|
184
645
|
function formatPercentage(value) {
|
|
185
646
|
if (!Number.isFinite(value)) {
|
|
186
647
|
return "—";
|
|
@@ -188,6 +649,7 @@
|
|
|
188
649
|
return `${(value * 100).toFixed(1)}%`;
|
|
189
650
|
}
|
|
190
651
|
|
|
652
|
+
/** @param {unknown} value */
|
|
191
653
|
function normaliseDecimal(value) {
|
|
192
654
|
if (typeof value !== "string") {
|
|
193
655
|
return "";
|
|
@@ -195,6 +657,7 @@
|
|
|
195
657
|
return value.replace(",", ".").trim();
|
|
196
658
|
}
|
|
197
659
|
|
|
660
|
+
/** @param {unknown} value */
|
|
198
661
|
function safeParseInt(value) {
|
|
199
662
|
if (typeof value !== "string") {
|
|
200
663
|
return NaN;
|
|
@@ -206,73 +669,60 @@
|
|
|
206
669
|
if (!/^\d+$/.test(trimmed)) {
|
|
207
670
|
return NaN;
|
|
208
671
|
}
|
|
209
|
-
|
|
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;
|
|
210
682
|
}
|
|
211
683
|
|
|
684
|
+
/** @param {number} value */
|
|
212
685
|
function formatLikelihood(value) {
|
|
213
|
-
if (
|
|
686
|
+
if (value === Infinity) {
|
|
214
687
|
return "∞";
|
|
215
688
|
}
|
|
689
|
+
if (!Number.isFinite(value)) return "—";
|
|
216
690
|
if (value === 0) {
|
|
217
691
|
return "0";
|
|
218
692
|
}
|
|
219
693
|
return value >= 10 ? value.toFixed(1) : value.toFixed(2);
|
|
220
694
|
}
|
|
221
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 */
|
|
222
708
|
function clamp(value, min, max) {
|
|
223
709
|
return Math.min(Math.max(value, min), max);
|
|
224
710
|
}
|
|
225
711
|
|
|
226
712
|
function buildSensitivityNote(value) {
|
|
227
|
-
|
|
228
|
-
return "Captures most cases with disease. Useful for screening.";
|
|
229
|
-
}
|
|
230
|
-
if (value >= 0.7) {
|
|
231
|
-
return "Moderate sensitivity: consider alongside clinical context.";
|
|
232
|
-
}
|
|
233
|
-
return "Low sensitivity: consider additional tests to reduce false negatives.";
|
|
713
|
+
return presentation.noteFor("buildSensitivityNote", value);
|
|
234
714
|
}
|
|
235
715
|
|
|
236
716
|
function buildSpecificityNote(value) {
|
|
237
|
-
|
|
238
|
-
return "Few false positives. Suitable for confirming diagnoses.";
|
|
239
|
-
}
|
|
240
|
-
if (value >= 0.7) {
|
|
241
|
-
return "Moderate specificity: confirm with other laboratory or clinical data.";
|
|
242
|
-
}
|
|
243
|
-
return "Low specificity: beware of false positives and their impact on treatment decisions.";
|
|
717
|
+
return presentation.noteFor("buildSpecificityNote", value);
|
|
244
718
|
}
|
|
245
719
|
|
|
246
720
|
function interpretLRPositive(value) {
|
|
247
|
-
|
|
248
|
-
return "LR+ is very high: a positive result virtually confirms the disease.";
|
|
249
|
-
}
|
|
250
|
-
if (value >= 10) {
|
|
251
|
-
return "LR+ >= 10 indicates strong evidence in favour of disease.";
|
|
252
|
-
}
|
|
253
|
-
if (value >= 5) {
|
|
254
|
-
return "Moderate LR+: substantially increases the probability of disease.";
|
|
255
|
-
}
|
|
256
|
-
if (value >= 2) {
|
|
257
|
-
return "Low LR+: limited gain; combine with other data.";
|
|
258
|
-
}
|
|
259
|
-
return "LR+ close to 1: a positive result barely changes the probability of disease.";
|
|
721
|
+
return presentation.noteFor("interpretLRPositive", value);
|
|
260
722
|
}
|
|
261
723
|
|
|
262
724
|
function interpretLRNegative(value) {
|
|
263
|
-
|
|
264
|
-
return "LR- is infinite: a negative result does not reduce the probability of disease.";
|
|
265
|
-
}
|
|
266
|
-
if (value <= 0.1) {
|
|
267
|
-
return "LR- <= 0.1 indicates strong evidence against disease.";
|
|
268
|
-
}
|
|
269
|
-
if (value <= 0.2) {
|
|
270
|
-
return "Moderate LR-: reduces the probability of disease.";
|
|
271
|
-
}
|
|
272
|
-
if (value <= 0.5) {
|
|
273
|
-
return "Low LR-: limited impact; consider further evaluation.";
|
|
274
|
-
}
|
|
275
|
-
return "LR- close to 1: a negative result does not rule out disease.";
|
|
725
|
+
return presentation.noteFor("interpretLRNegative", value);
|
|
276
726
|
}
|
|
277
727
|
|
|
278
728
|
function buildProbabilityBar(value, width) {
|
|
@@ -283,23 +733,41 @@
|
|
|
283
733
|
}
|
|
284
734
|
|
|
285
735
|
return {
|
|
736
|
+
buildBiasWarnings,
|
|
286
737
|
buildProbabilityBar,
|
|
287
738
|
buildSensitivityNote,
|
|
288
739
|
buildSpecificityNote,
|
|
740
|
+
calcCohenKappa,
|
|
741
|
+
calcDOR,
|
|
742
|
+
calcLogRatioCI,
|
|
743
|
+
calcPostTestCI,
|
|
289
744
|
calcWilsonInterval,
|
|
290
745
|
calculateMetrics,
|
|
746
|
+
calculateResults,
|
|
747
|
+
chainedPreTestProbability,
|
|
291
748
|
calculateOdds,
|
|
749
|
+
calculateROC,
|
|
292
750
|
calculateRatio,
|
|
751
|
+
calculateThresholds,
|
|
293
752
|
clamp,
|
|
294
753
|
formatLikelihood,
|
|
754
|
+
formatNNS,
|
|
295
755
|
formatPercentage,
|
|
296
756
|
formatValue,
|
|
757
|
+
generateSyntheticRocPoints,
|
|
758
|
+
interpretDOR,
|
|
759
|
+
interpretKappa,
|
|
297
760
|
interpretLRNegative,
|
|
298
761
|
interpretLRPositive,
|
|
762
|
+
interpretNNS,
|
|
763
|
+
invStdNormal,
|
|
299
764
|
multiplyOdds,
|
|
300
765
|
normaliseDecimal,
|
|
301
766
|
probabilityFromOdds,
|
|
302
767
|
safeParseInt,
|
|
768
|
+
stdNormalCdf,
|
|
303
769
|
validateInputs,
|
|
770
|
+
validateRocRows,
|
|
771
|
+
parseProbability,
|
|
304
772
|
};
|
|
305
773
|
}));
|