An Interactive Fagan Nomogram

mathematics
data-science
Published

November 14, 2020

The Fagan nomogram (Fagan 1975) is a nomogram (Wikipedia contributors 2022) 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 (Casscells, Schoenberger, and Graboys 1978).

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.