An Interactive Fagan Nomogram

mathematics
data-science
Published

November 14, 2020

The Fagan nomogram is a nomogram that computes the probability of the presence of some condition based on an imperfect test and varying pre-test probabilities. It is a very handy tool to understand Bayes’s Theorem “physically”. People typically have a sense that the less powerful the test, the less likely it is that a positive test result means the presence of the condition. But people are much less likely to grasp the role of the pre-test probability .

As an illustration, consider the example illustrated in the default setting of the nomogram below. If only 10% of the population exhibit a particular kind of condition, then even if a test gives a ratio of true positives to false positives at 10 to 1, only 50% of the people tested positive will actually exhibit the condition.

Similarly, if only 10% of the population exhibits the condition, then in order to be 90% sure that a positive result indicates the presence of the condition, the test can give a false-positive result only 1 every 100 times it gives true positive results. Intuitively, what’s going on is that the base-10 logarithm of the “likelihood ratio” (10 and 100 respectively in the examples above) is the “number of nines added to the baseline probability”, interpreting a probability of 0.1 as having “negative 1 nine”.

The original nomogram was meant to be used with a physical ruler to do the calculations. Here, you can grab the circles and move them around to change the settings.

Code
``````import { svg } from "/src/cscheid/cscheid.js";

{
svg.setupD3Prototype(d3);

let svgNode = d3.select("#main")
.append("svg")
.attr("viewBox", "0 0 920 400")
.attr("width", "100%")
.attr("height", "100%");

const scaleBounds = [40, 700];

let preTestScaleLogOdds = d3.scaleLinear()
.domain([3, -3])
.range(scaleBounds);

let postTestScaleLogOdds = d3.scaleLinear()
.domain([-3, 3])
.range(scaleBounds);

let logLikelihoodRatioScale = d3.scaleLinear()
.domain([-6, 6])
.range(scaleBounds);

let labels = ['pre-test probability',
'likelihood ratio',
'post-test probability'];

let yScale = d3.scaleLinear().domain([0,2]).range([100, 300]);

svgNode.append("g")
.selectAll("text")
.data(labels)
.enter()
.append("text")
.attr("x", 910)
.attr("y", function(d, i) { return yScale(i); })
.attr("dy", 3)
.text(function(d) { return d; })
.attr("class", "label");

let preTestLineG = svgNode.append("g");
let preTestLine = preTestLineG
.append("line")
.attr("x1", preTestScaleLogOdds(-3))
.attr("x2", preTestScaleLogOdds(3))
.attr("y1", 100)
.attr("y2", 100)
.attr("class", "test-line");

let lrLineG = svgNode.append("g");
let lrLine = lrLineG
.append("line")
.attr("x1", logLikelihoodRatioScale(-4))
.attr("x2", logLikelihoodRatioScale(4))
.attr("y1", 200)
.attr("y2", 200)
.attr("class", "test-line");

let postTestLineG = svgNode.append("g");
let postTestLine = postTestLineG
.append("line")
.attr("x1", postTestScaleLogOdds(-3))
.attr("x2", postTestScaleLogOdds(3))
.attr("y1", 300)
.attr("y2", 300)
.attr("class", "test-line");

//////////////////////////////////////////////////////////////////////////////
// this could be solved automatically..

function expFmt(v) {
let l = Math.pow(10, v);
let r = fmt(l / (1 + l));
return r
.replace(/0+\$/, "")
.replace(/(\..*)09+\$/, (match, p1) => `\${p1}1`); // i'm so sorry, universe.
}

function lrFmt(v) {
let l = Math.pow(10, v);
if (l == ~~l)
return String(l);
else
return String(l)
.replace(/0+\$/, "")
.replace(/(\..*)09+\$/, (match, p1) => `\${p1}1`); // i'm so sorry, universe.
}

let preProbTicks = {
list: [-3, -2, -0.954245, 0, 0.954245, 2, 3],
scale: preTestScaleLogOdds,
fmt: expFmt
};

let postProbTicks = {
list: [-3, -2, -0.954245, 0, 0.954245, 2, 3],
scale: postTestScaleLogOdds,
fmt: expFmt
};

let lrTicks   = {
list: [-4, -3, -2, -1, 0, 1, 2, 3, 4],
scale: logLikelihoodRatioScale,
fmt: lrFmt
};

let fmt = d3.format(".3f");

function addTicks(ticks)
{
return function(sel) {
let gs = sel
.enterMany(ticks.list)
.append("g");
gs.append("line")
.attr("x1", function(d) { return ticks.scale(d); })
.attr("x2", function(d) { return ticks.scale(d); })
.attr("y1", -5).attr("y2", 5)
.attr("class", "test-line");
gs.append("text")
.attr("x", function(d) { return ticks.scale(d); })
.attr("y", -15)
.attr("class", "tick-label")
.text(function(d) { return ticks.fmt(d); });
};
}

preTestLineG
.append("g")
.attr("transform", svg.translate(0, 100))
.callReturn(addTicks(preProbTicks));

lrLineG
.append("g")
.attr("transform", svg.translate(0, 200))
.callReturn(addTicks(lrTicks));

postTestLineG
.append("g")
.attr("transform", svg.translate(0, 300))
.callReturn(addTicks(postProbTicks));

let nomogramLine = svgNode.append("line")
.attr("x1", preTestScaleLogOdds(-1))
.attr("y1", 100)
.attr("x2", postTestScaleLogOdds(0))
.attr("y2", 300)
.attr("class", "test-line");

function updateLine() {
nomogramLine.attr("x1", preTestHandle.attr("cx"));
nomogramLine.attr("x2", postTestHandle.attr("cx"));
}

function dragAttrs(sel) {
return sel.attr("r", 5)
.attr("fill", "white")
.attr("stroke", "black")
.attr("cursor", "pointer");
}

let preTestHandle = svgNode.append("circle")
.attr("cx", preTestScaleLogOdds(-1))
.attr("cy", 100)
.callReturn(dragAttrs)
.call(d3.drag().on("drag", function(event) {
debugger;
d3.select(this).attr("cx", event.x);
let lrX = Number(lrHandle.attr("cx"));
let dx = lrX - event.x;
postTestHandle.attr("cx", lrX + dx);
updateLine();
}));

let lrHandle = svgNode.append("circle")
.attr("cx", 0.5 * (postTestScaleLogOdds(0) + preTestScaleLogOdds(-1)))
.attr("cy", 200)
.callReturn(dragAttrs)
.call(d3.drag().on("drag", function(event) {
debugger;
d3.select(this).attr("cx", event.x);
let ptX = Number(preTestHandle.attr("cx"));
let dx = event.x - ptX;
postTestHandle.attr("cx", event.x + dx);
updateLine();
}));

let postTestHandle = svgNode.append("circle")
.attr("cx", postTestScaleLogOdds(0))
.attr("cy", 300)
.callReturn(dragAttrs)
.call(d3.drag().on("drag", function(event) {
debugger;
d3.select(this).attr("cx", event.x);
lrHandle.attr("cx", 0.5 * (event.x + Number(preTestHandle.attr("cx"))));
updateLine();
}));
}``````

References

Casscells, Ward, Arno Schoenberger, and Thomas B Graboys. 1978. “Interpretation by Physicians of Clinical Laboratory Results.” New England Journal of Medicine 299 (18): 999–1001.
Fagan, T. J. 1975. “Nomogram for Bayes Theorem.” N Engl J Med 293 (5): 257.
Wikipedia contributors. 2022. “Nomogram — Wikipedia, the Free Encyclopedia.” https://en.wikipedia.org/w/index.php?title=Nomogram&oldid=1085193589.