Most health calculators on the web are black boxes. You type in your age, a number appears, and you have no idea where it came from.

When we built an IVF success calculator for Fertilio, a site that compares fertility clinics across Europe, I wanted the opposite: every number traceable to a public source, a formula small enough to read in one sitting, and limitations written down instead of hidden. No framework, no backend, no tracking of what people enter.

This post walks through how it works, including the parts I am not fully happy with.

The data source

Germany's national IVF registry (the D.I.R.) publishes a yearbook with outcomes from fertility centers across the country. The 2024 edition covers 131,801 treatment cycles from 139 centers.

The yearbook is detailed, but it is a long PDF written for clinicians. For a calculator, the useful parts are:

  • clinical pregnancy rate per embryo transfer, by single year of age
  • miscarriage rates by age bracket
  • outcomes by transfer day (day 2 to day 6 embryos)
  • outcomes by diagnosis (male factor, female factor, unexplained, both)
  • and, for the first time in this edition, outcomes stratified by AMH, a hormone used to estimate ovarian reserve (about 42,600 cycles had an AMH value recorded)

For factors the registry does not break down, like BMI and smoking, the calculator uses multipliers from published studies and meta-analyses. Those are documented separately, because their evidence base is clearly weaker than the registry's.

Step 1: a lookup table instead of a model

The base of everything is a plain object mapping age to the registry's clinical pregnancy rate per transfer (fresh IVF and ICSI cycles, weighted average):

const DATA = { baseRates: { 25: 39.7, 26: 40.9, 27: 41.1, 28: 41.8, 29: 41.3, 30: 39.7, 31: 39.0, 32: 39.2, 33: 37.6, 34: 37.4, 35: 35.5, 36: 33.1, 37: 31.3, 38: 29.2, 39: 26.3, 40: 22.5, 41: 20.1, 42: 15.2, 43: 11.5, 44: 8.8, 45: 3.8, // ... }, }; function getBaseRate(age) { const clamped = Math.max(20, Math.min(50, Math.round(age))); return DATA.baseRates[clamped]; } 

No interpolation, no curve fitting. The registry already reports single years of age, so a lookup is both the simplest and the most honest option. If you squint at the numbers, you can see the shape everyone in reproductive medicine knows: a plateau until the early thirties, then a steep drop after 37.

Step 2: multiplicative adjustment factors

Everything else is expressed as a factor relative to a reference group, where 1.0 means "no effect":

const DATA = { // ... amhFactors: { // factor = pregnancy rate in AMH group / rate of whole age group '<35': { low: 0.877, mid: 1.013, high: 1.039 }, // <1.0 / 1.0-3.5 / >3.5 ng/ml '35-39': { low: 0.859, mid: 1.037, high: 1.158 }, // ... }, }; function getAmhFactor(age, amhValue) { const amh = parseFloat(amhValue); if (Number.isNaN(amh)) return 1.0; // AMH is optional const group = age < 35 ? '<35' : age < 40 ? '35-39' : '>=40'; const f = DATA.amhFactors[group]; if (amh < 1.0) return f.low; if (amh <= 3.5) return f.mid; return f.high; } 

The AMH factors are derived directly from the registry: the pregnancy rate of each AMH group divided by the rate of its whole age group. Transfer day, diagnosis and cycle type (fresh or frozen) work the same way.

The core calculation is then just a chain of multiplications:

function calculate(inputs) { const age = Math.max(20, Math.min(50, Math.round(inputs.age || 32))); let rate = getBaseRate(age); rate *= getAmhFactor(age, inputs.amh); rate *= DATA.transferDayFactors[inputs.transferDay] || 1.0; rate *= DATA.cycleFactors[inputs.cycleType] || 1.0; rate *= DATA.diagnosisFactors[inputs.diagnosis] || 1.0; rate *= getBmiFactor(inputs.bmi); rate *= DATA.smokingFactors[inputs.smoking] || 1.0; rate *= DATA.pregnancyFactors[inputs.previousPregnancy] || 1.0; rate *= DATA.durationFactors[inputs.duration] || 1.0; rate *= getAttemptFactor(inputs.attempt); // keep results inside a plausible range const pregnancyRate = Math.max(2, Math.min(55, rate)); // ... } 

Every unknown input falls back to 1.0, so leaving a field empty never silently distorts the result. That matters because many users do not know their AMH value or the planned transfer day.

Step 3: from pregnancy to birth

A clinical pregnancy is not a birth. The registry reports miscarriage rates rising from about 15 percent under 30 to around 60 percent from the mid-forties. So the calculator converts:

const miscarriageRate = getMiscarriageRate(age); // e.g. 0.22 for 35-37 const birthRate = pregnancyRate * (1 - miscarriageRate); 

Showing both numbers side by side turned out to be one of the most important design decisions. A 46 percent pregnancy chance and a 36 percent birth chance tell a very different story, and users deserve to see both.

Step 4: cumulative chances over several transfers

Patients rarely ask "what are my odds this one time?" They ask "what are my odds if I try a few times?" The registry's cumulative data shows the chance of pregnancy rising from about 34 percent after one transfer to about 69 percent after four.

The calculator approximates this with the complement of repeated failure, with a small decline per attempt (derived from the registry's cumulative data):

const p = pregnancyRate / 100; const attemptFactors = [1.0, 0.87, 0.86, 0.83]; const cumulative = []; let failProb = 1; for (let i = 0; i < 4; i++) { const pThis = p * attemptFactors[i]; failProb *= 1 - Math.min(pThis, 0.55); cumulative.push(Math.min(95, (1 - failProb) * 100)); } 

Two caps keep this from producing nonsense: a single attempt never counts above 55 percent, and the cumulative result never exceeds 95 percent. A calculator that tells someone they have a 99 percent chance is simply wrong.

What this model gets wrong

I think every health tool should publish this part. Most skip it.

It is not a regression model. Multiplying factors assumes they are independent. They are not. AMH and age are correlated, so applying both partly double-counts the same effect. A proper approach would fit a model on patient-level data, which the public registry does not provide.

The AMH data is young. This is the first yearbook with AMH stratification. It uses only three groups, and AMH was recorded for just a subset of cycles.

It averages across all centers. Individual clinics can be meaningfully better or worse than the national average. The calculator cannot know that.

Secondary factors are approximations. The BMI, smoking and duration multipliers come from international studies with different populations and definitions.

We put all of this, plus every source and a fully worked example, on a separate methodology page. I am convinced that page matters more for trust than the calculator UI. "Here is exactly what this can and cannot tell you" is more convincing than any polished result screen.

A few implementation notes

  • Vanilla JS, no build step. The whole calculator is one IIFE of about 500 lines, including rendering and animation. For a single interactive widget, a framework would have been pure overhead.
  • Inputs never leave the browser. There is no API call. Health data is sensitive, and the simplest way to protect it is not to collect it.
  • Data and logic are separated. All numbers live in one DATA object with comments pointing to their source. When the next yearbook comes out, updating the calculator mostly means editing that object.
  • Results are clamped, rounded and labeled. One decimal place, a clear "estimate" label, and both pregnancy and birth rates. False precision is its own kind of misinformation.

Takeaways for anyone building calculators on public data

  1. Start from the most authoritative source available, even if it is an unfriendly PDF.
  2. Prefer lookup tables over clever math when the source already reports the granularity you need.
  3. Make missing inputs neutral. Default factors of 1.0 keep partial input honest.
  4. Cap the output. Real-world probabilities have ceilings; your code should too.
  5. Publish the limitations next to the tool. It costs nothing and earns more trust than any design polish.

If you have built something similar for other public datasets, from court statistics to energy data, I would love to hear how you handled the trade-off between simplicity and accuracy. And if you spot a flaw in the approach above, please tell me in the comments. That is exactly what publishing the method is for.