Guide · AI and GenAI
Fine-Tuning Case Study: A Model for Hospital Billing Codes
24 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- This fine tuning case study follows a made-up but realistic example: a team that turns hospital discharge notes, the doctor's summary at the end of a stay, into billing codes. Fine-tuning, meaning training an existing model further on your own worked examples, beat a RAG setup on accuracy, cost and speed.
- RAG, short for retrieval-augmented generation, searches documents and pastes the closest matches into the model's prompt. It lost here because coding is a skill learned from thousands of past cases, not a fact to look up.
- RAG still wins for facts that change often, for answers that must point to a document, and, as a rough guide, when you have only a few hundred labelled examples.
- The fine-tuned model never works alone. Plain code flags any code missing from today's official lists, and a person checks every suggested rare, costly code. Coder corrections become next month's training data.
- Beginners can stop after the section on choosing RAG instead. Engineers then get checkable LoRA maths, a routing function with its expected output, why whole-answer confidence punishes long answers, and generating vs classifying codes.
What goes wrong when you ask a model to pick hospital billing codes?
It picks codes that look right but are wrong in small, costly ways. In a 2024 Mount Sinai study, general large models given a code's description wrote back the exact code less than half the time. This fine-tuning case study follows one team that had to do better.
A billing code is a short label for one diagnosis or one procedure. Insurers pay hospitals based on these codes. A wrong code means a rejected claim, a late payment, or money the hospital must return later.
Diagnoses are often coded with ICD-10, the World Health Organization's list of diseases. For example, K80.2 means a gallstone without an inflamed gallbladder. Procedures are often billed from a hospital's or insurer's list of packages, each with its own code.
In the Mount Sinai study, models got each code's official description and had to write the code. Every model scored below 50% exact match, meaning the code it wrote was exactly the original code. The best one matched about 34% of ICD-10-CM codes, the US adaptation of ICD-10.
Many misses were near-misses: a related code that was less precise. The researchers themselves note the task was artificial, and real notes may be harder. Knowing medicine in general is not the same as coding like a trained coder.
Picture a common setup. A health-tech team in Lucknow builds software that suggests billing codes for three hospital chains. The setup is made up, but the problem is real. The chains send about 9,000 discharge notes a day.
A discharge note is the summary a doctor writes when a patient leaves hospital. It says what was wrong, what was done and which medicines to take at home. Coding one from scratch takes a trained coder about 12 minutes (made up).
At 9,000 notes a day, that is 1,800 coder-hours a day, or about 240 coders on 7.5-hour shifts. The team's job is to suggest codes, so coders mostly check instead of starting from a blank screen.
The first try: RAG over the code lists
The first version used RAG. A search step finds the most relevant documents, the program pastes them into the prompt, and the model answers from them. The prompt is the text your program sends to the model.
The team's store held every code with its official description, plus each chain's coding rules. For each note, the system found the 30 closest code descriptions and asked a large general model to pick the right ones.
The first version, built on RAG
Drag to exploreEvery note took the same path. Notice the search step: it matches words, not coding habits, so the right code was sometimes never shown to the model.
Diagram connections:
- Discharge note arrives to Search code descriptions
- Search code descriptions to Keep the 30 closest codes
- Keep the 30 closest codes to Build a long prompt
- Build a long prompt to Large model picks codes
- Large model picks codes to Coder fixes the codes
- Discharge note arrives: Full of shorthand like lap chole
- Search code descriptions: Matches words, not doctor shorthand
- Keep the 30 closest codes: Sometimes the right code is missing
- Build a long prompt: Note, 30 codes and every house rule
- Large model picks codes: Applies order and rules unevenly
- Coder fixes the codes: 42% of notes needed a fix (made up)
The team tested it on 2,000 notes that senior coders had already coded. It got every code right on 58% of notes (made up). The lead coder read the misses one by one and found three patterns.
- Shorthand. Doctors write lap chole for keyhole gallbladder removal, and k/c/o DM for a known case of diabetes. The search matched these to the wrong descriptions, or to none.
- Order. The principal diagnosis, the main reason for the stay, must come first. The model often listed codes in the order the note happened to mention them.
- House rules. One chain bills a hernia repair with mesh as one package, and another bills the mesh separately. The rules sat in the prompt, but the model applied them unevenly.
The team kept adding rules to the prompt. It grew to about 6,000 tokens per note, where tokens are the small chunks of text that model services count and charge for. Each note cost Rs 1.20 and took 5 seconds (made up), and accuracy barely moved.
What does a fine-tuning case study look like for hospital billing codes?
It looks like a narrow, high-volume task with a fixed list of answers and years of labelled examples. In that setting, training a model once can beat searching for context on every request.
Fine-tuning means taking a model that already understands language and training it a little more on your own examples. Each example pairs an input with the exact output you want. Here, the input is a discharge note and the output is its codes.
The team had something many teams lack: a large pile of labelled data. Labelled data means examples where an expert has already written the right answer. The chains had coded every note for four years, and senior coders had audited a share of them.
The team chose a small open-weight model, one whose weights you can download and run on your own servers. Weights are the numbers inside a model that decide how it behaves. Patient notes then never leave the team's servers.
Think of a young tabla player doing riyaz, the daily practice, under a guru. Here is how that picture maps to this case.
- The written bols, the spoken syllables of a rhythm, are the code descriptions RAG pastes into the prompt. Reading them mid-concert helps a little, but the hands still stumble.
- Years of riyaz are the thousands of coded notes the model trains on.
- The guru's corrections are the senior coders' audits, which decide which examples count as right.
- Playing without the page is the fine-tuned model coding a note from a short prompt.
- Where the picture breaks: a tabla player can read a new composition on the spot. This fine-tuned model sees only the note, so it has no reliable way to use a code its training examples never showed.
Why does fine-tuning beat RAG for medical coding?
Fine-tuning beats RAG here because the hard part is a learned habit, not a missing fact. The RAG version could often already see the right code descriptions. What it lacked was the judgement the senior coders had built over years.
- The answer list is closed. Every valid code already exists, so the model must choose well, not find new information.
- The knowledge lives across examples. No single document says that lap chole plus a stone on the scan means K80.2 plus the chain's gallbladder package. Thousands of past notes show it.
- The output has a strict shape: codes in one format, principal diagnosis first. Training turns that shape into a habit, so the long prompt of rules can go.
- The volume is high. At 9,000 notes a day, a short prompt to a small model saves real money and waiting time.
| RAG version | Fine-tuned version | |
|---|---|---|
| Notes with every code right | 58% | 83% |
| Notes with an invented code, before checks | About 60 (3 in 100) | About 5 (1 in 400) |
| Prompt size per note | About 6,000 tokens | About 900 tokens |
| Cost per note | Rs 1.20 | Rs 0.15 on own servers |
| Model cost per 30-day month at 9,000 notes a day | Rs 3.24 lakh (324,000) | Rs 40,500 |
| Time per note | About 5 seconds | Under 1 second |
| Where notes are processed | An outside model service | The team's own servers |
| Handles a brand-new code | Yes, once it is in the store | Not reliably until retrained |
What just happened? Cost and time fell the most, because the prompt shrank about seven times and a small model answers faster. Accuracy rose because habits moved from the prompt into the weights.
The last row is the catch. Fine-tuning won this case, but it has a real weakness, and much of the later design exists to cover it.
So fine-tune for skills and output formats, and keep facts that change in a lookup. In that study, accuracy on new facts rose steadily as training showed more rewordings of each fact, but the authors say this still needs more research.
When should you choose RAG over fine-tuning instead?
Choose RAG when answers depend on facts that change often, when you must cite a source, or when you have few labelled examples. Many real systems use both, as this team ends up doing.
These signs point toward RAG:
- The answer is written in a document, such as a policy or a price list.
- The documents change weekly, and an old answer would do harm.
- Users need to see where an answer came from.
- As a rough guide, you have hundreds of labelled examples, not tens of thousands.
These signs point toward fine-tuning:
- The task is a repeated skill with a fixed set of answers, like sorting, tagging or coding.
- Experts have already labelled thousands of past cases, and you can tell which labels to trust.
- Your prompt keeps growing with rules, and accuracy stops improving.
- Volume, speed or privacy makes a small model on your own servers worth the training effort.
Always try a plain prompt first. It is the cheapest way to learn how hard the task is, and it gives you a baseline to beat. The RAG version was not wasted either: it gave the team a measured baseline and a list of failure patterns to fix.
How do you build the training data for a fine-tuned coding model?
You build it from past notes that experts already coded, cleaned so the model learns only from answers you trust. A tabla student who practises a wrong stroke ten thousand times learns the wrong stroke perfectly, and bad labels work the same way.
Collect past notes
Four years at 9,000 notes a day is about 1.3 crore (13 million) coded notes, each with the codes that were billed.
Keep only audited labels
Senior coders had audited about 2% of notes, roughly 260,000 (made up). Only these became the first training set. A paid claim was not treated as proof, because a claim can be paid even when a code is missing.
Remove patient identity
Names, phone numbers and patient IDs became placeholders such as PATIENT_1. The model needs the medicine in the note, not the person.
Split by date
Audited notes from the last three months became the test set, held back from training. The model trains on older notes and is scored on newer ones, as it will be in real use. A second score uses only patients missing from training.
Check the rare codes
The lead coder listed rare but costly codes, such as some cancer surgeries. Each one either had enough examples or was marked so that any note where the model suggests it goes to a person.
The code below turns audited rows into training examples. The output file has one JSON example per line, a format often called JSON Lines. The two sample rows at the bottom let you check it.
import json
def write_training_file(rows, path):
kept = 0
with open(path, "w", encoding="utf-8") as f:
for row in rows:
if row["audit_status"] != "approved":
continue # never train on codes nobody checked
example = {
"input": row["masked_note"],
"output": " | ".join(row["codes"]), # principal first
}
f.write(json.dumps(example, ensure_ascii=False) + "\n")
kept += 1
return kept
rows = [
{"audit_status": "approved", "codes": ["K80.2", "PKG-104"],
"masked_note": "PATIENT_1, 46 F. Stone on scan. Lap chole done."},
{"audit_status": "pending", "codes": ["K80.2"],
"masked_note": "PATIENT_2, 51 M. Lap chole planned."},
]
print(write_training_file(rows, "train.jsonl"))
print(open("train.jsonl", encoding="utf-8").read(), end="")Run it with Python 3 and you should see exactly these two lines: the count of kept notes, then the one example written to train.jsonl.
1
{"input": "PATIENT_1, 46 F. Stone on scan. Lap chole done.", "output": "K80.2 | PKG-104"}Two lines carry the design. The audit check drops the second note, which no senior confirmed. The join keeps the codes in billing order, so the model learns which code goes first, not just which codes appear.
That toy is not a language model, but the trap is the same. A date split stops the model training on the future, as in real use. It does not stop near-copies, because readmissions weeks apart land on both sides of the cut.
When the toy spread each patient's notes over months, a date split scored 79%, while notes from patients it never trained on scored 56%. So report the date-split score next to an unseen-patient score.
Rare codes need extra care. A 2023 review retrained six published coding models on MIMIC-IV, a de-identified dataset from one US hospital, and found all of them struggled with rare codes.
The strongest of the six never correctly predicted 2,928 of the 5,794 ICD-10 diagnosis codes in the authors' data split. So when this model suggests a rare, costly code, a person always checks it.
The harder case is a rare code the model leaves out. If that note passes every other check, only the fast-lane coder's glance can catch it before the bill, and the 2% audit may find it later. So before routing, the team also runs a plain keyword search: notes that mention cancer or major surgery go straight to full review.
How do you fine-tune a model without retraining all of it?
You use a method such as LoRA, short for low-rank adaptation. LoRA freezes the original weights and trains two small extra matrices beside chosen weight matrices. A matrix is a grid of numbers.
Full fine-tuning changes every weight in the model. That needs a lot of GPU memory, the memory on the chips that do the heavy maths, and a full copy of the model per version. LoRA leaves the base model untouched, and only the small add-on, called an adapter, learns.
The maths you can check
Take one weight matrix of 4,096 by 4,096 numbers. Full fine-tuning trains all 16,777,216 of them. LoRA with rank 8 trains a 4,096 by 8 matrix and an 8 by 4,096 matrix, where rank is the width of these thin matrices.
Check it: 2 times 4,096 times 8 is 65,536 numbers, which is 256 times fewer for that one matrix. Multiplying the two thin matrices gives a full-size change that is added to the frozen weights.
The paper starts one thin matrix at zero, so at the start of training the model behaves exactly like the base model. In most experiments it adapted only two attention weight matrices, query and value, where attention is the part of each layer that decides which words to look at.
After training, you can add the change into the original weights once. The merged model then runs with no extra delay. Or you can keep adapters separate and swap them, for example one per hospital chain on one base model.
What the original paper reported
The 2021 LoRA paper compared it with full fine-tuning of a 175-billion-weight model using Adam, a common optimiser, meaning the rule that updates weights during training. It reported about 10,000 times fewer trainable weights.
Training memory fell from 1.2 TB to 350 GB, and a checkpoint, the saved file of trained weights, shrank from 350 GB to 35 MB. Quality was on par or better on the four model families tested, on general language benchmarks, meaning standard public test sets, not medical coding. Test on your own notes.
For this team, one training run took about a day on two rented GPUs (made up). Each adapter file was small, so the team kept every version and could roll back in minutes. The diagram shows the monthly training loop.
How the team trains a new adapter
Drag to exploreNotice the question box: a new adapter ships only if it beats the current one on held-back notes, and a failure sends the team back to the labels, not the settings.
Diagram connections:
- Past coded notes to Keep audited codes only
- Keep audited codes only to Remove patient identity
- Remove patient identity to Split by date
- Split by date to Train a LoRA adapter
- Train a LoRA adapter to Beats the current adapter?
- Beats the current adapter? to Ship to production (Yes)
- Beats the current adapter? to Study the misses (No)
- Study the misses to Keep audited codes only (Fix labels, try again)
- Past coded notes: Four years from three chains
- Keep audited codes only: Unchecked labels are dropped
- Remove patient identity: Names and IDs become placeholders
- Split by date: Last three months held back for tests
- Train a LoRA adapter: Base model frozen, adapter learns
- Beats the current adapter?: Scored on the held-back notes
- Ship to production: Old adapter kept for rollback
- Study the misses: Lead coder and engineer read failures
When an adapter failed, the reason was usually in the data, not the training settings. Twice, the lead coder found that one chain had changed how it billed a procedure partway through a year, so the older labels had to be fixed.
How do you check a fine-tuned model's codes before they reach a bill?
You check every code in plain code against the current official lists, then send any note the model is unsure about to a human coder. The model suggests codes, and rules and people decide.
What the confidence score really measures
A model writes its answer one token at a time and gives each token a probability. The probability of the whole answer is all those token probabilities multiplied together.
Only the short one. 0.99 to the power 10 is 0.904, but 0.99 to the power 30 is 0.740. A whole-answer score punishes long answers, so notes with many codes would all land in full review.
So the team scores each code separately, multiplying only the token probabilities inside that code, and passes the lowest code score as confidence. The 0.90 cutoff was picked by testing several values on held-back notes, because these scores are only a rough signal. Codes that take more tokens still score a little lower, so check the cutoff for each kind of code.
The routing function
In the code below, valid_codes is a set built from today's ICD-10 list and each chain's package list. The function returns where the note should go, and why. The four calls at the bottom let you check it.
ALWAYS_HUMAN = {"C15.3", "C25.0"} # rare, costly cancer codes
def route(codes, confidence, valid_codes):
if not codes:
return "full_review", "model gave no codes"
unknown = [c for c in codes if c not in valid_codes]
if unknown:
return "full_review", f"not on any current list: {unknown}"
if any(c in ALWAYS_HUMAN for c in codes):
return "full_review", "rare code, always checked by a person"
if confidence < 0.90:
return "full_review", "model is unsure"
return "fast_lane", "passed every check"
valid = {"K80.2", "E11.9", "C15.3", "C25.0", "PKG-104"}
print(route(["K80.2", "PKG-104"], 0.95, valid))
print(route(["K80.2", "K80.9X"], 0.97, valid))
print(route(["C25.0"], 0.99, valid))
print(route(["K80.2", "E11.9"], 0.82, valid))Run it with Python 3.6 or later, which f-strings need, and you should see these four lines.
('fast_lane', 'passed every check')
('full_review', "not on any current list: ['K80.9X']")
('full_review', 'rare code, always checked by a person')
('full_review', 'model is unsure')What just happened? The first note passed every gate. The second had K80.9X, which is not on the list, so an exact set lookup caught it. The third was confident but rare. The fourth was valid but under 0.90.
The order of checks matters. An invented code is caught first, by a lookup the model cannot argue with. Rare codes come next, so even a confident model never bills a rare code it suggested without a person. Confidence is the last gate, not the only one.
The coding system in production
Drag to exploreFollow a note from the note box to the claim. Notice that two paths feed the next training set: full reviews and the random fast-lane audit, so training does not tilt toward hard notes only.
Diagram connections:
- Discharge note to Fine-tuned model
- Fine-tuned model to All codes valid?
- All codes valid? to Confident and not rare? (Yes)
- All codes valid? to Full review (No)
- Confident and not rare? to Fast lane (Yes)
- Confident and not rare? to Full review (No)
- Fast lane to Claim to insurer
- Full review to Claim to insurer
- Full review to Next training set (Corrections)
- Fast lane to Next training set (2% random audit)
- Discharge note: Sent by the chain's hospital system
- Fine-tuned model: Suggests codes, principal first
- All codes valid?: Checked against today's code lists
- Confident and not rare?: Score 0.90 or more, no rare code or keyword
- Fast lane: Coder glances, then approves
- Full review: A coder codes it carefully
- Claim to insurer: Sent with the approved codes
- Next training set: Corrections saved for retraining
In the first month, about 70% of notes went to the fast lane, and coders cleared each in about a minute (made up). The other 30% still took about 12 minutes each.
Check the maths: 6,300 fast notes take 105 coder-hours, 2,700 full reviews take 540, and auditing 2% of the fast lane adds about 25. That is about 670 coder-hours a day instead of 1,800.
How do you handle new billing codes after fine-tuning a model?
You keep the code lists outside the model and retrain on a schedule. The prompt here holds only the note, so the model has never seen a brand-new code used. This is where a lookup still earns its place.
Code lists change. In this setup, the chains add and retire packages every few months. Diagnosis lists change too: the WHO's ICD-11, the successor to ICD-10, came into effect on 1 January 2022, and moving to it would mean a new list.
- The code lists live in a versioned table, outside the model. The route check always uses today's version, so a retired code is caught at once.
- When a chain renames a package, a small mapping in code turns the old code into the new one until the next retraining.
- When a truly new code appears, notes that may need it go to full review. The coder screen shows the new code's description, found by a simple search.
- Retraining runs every month on the latest audited notes. Once coders have used a new code in enough audited notes, the model learns it like any other.
So the final system is not fine-tuning instead of retrieval. It is fine-tuning for the skill, plus plain lookups for the facts that change. The model never has to remember which codes exist today.
Should you fine-tune a model to generate codes or to classify them?
Either can work, and they fail in different ways. This team fine-tuned a model to generate codes as text. The six research models in the 2023 review are instead classifiers that score every code on a fixed list.
Each one outputs a separate probability for every code, and a cutoff decides which codes to keep. The table compares the two designs.
| Question | Generate codes as text | Classify over a fixed list |
|---|---|---|
| What comes out | A string of codes, principal first | One probability for every code on the list |
| Can it output an invalid code? | Yes, so the list check is required | Not outside its list, but the list itself can go stale |
| Does it learn code order? | Yes, from the order in the examples | No, ranking the principal diagnosis needs another step |
| Adding a new code | Needs audited examples, then retraining | Needs a new output on the list, then retraining |
| Confidence per code | Needs extra work from token probabilities | Built in, one probability per code, but still needs checking on held-back notes |
The team picked generation because order and house rules mattered, and the list check closes its main gap. If your task has no order and a very large code list, a classifier is worth testing on the same held-back notes.
How do you explain this fine-tuning case study in a system design interview?
Explain it as a choice backed by evidence: start with the cheaper option, show where it fails, then show why training fixes those failures and what guards the new risks.
Describe the task
Discharge notes in, billing codes out, from a fixed list, in a fixed order, at about 9,000 notes a day.
Start with a baseline
Say you would try prompting and RAG first, and measure them on notes that experts already coded.
Name the failure patterns
Shorthand, code order and house rules. Explain that these are habits spread across examples, not facts in a document.
Show the training data plan
Audited labels only, patient identity removed, split by date, and rare codes listed for people.
Add the guards
A code list check, a per-code confidence cutoff, full review for suggested rare codes or cancer keywords, and a weekly fast-lane audit.
Plan for change
Code lists outside the model, monthly retraining, and corrections from coders feeding the next training set.
Questions people ask
- When does fine-tuning beat RAG?
- Fine-tuning tends to beat RAG when the task is a repeated skill with a fixed set of answers and you have many trusted examples. Coding hospital notes, tagging support tickets and sorting documents are good fits. RAG stays better for facts that change or answers that need a source.
- How much data do you need to fine-tune a model for classification?
- There is no fixed number, so measure it. Train on a few thousand good examples, score on held-back data, then double the data and score again. If scores keep rising, more data helps. Label quality matters more than size, so audited examples beat a bigger, messier pile.
- Can a fine-tuned model learn new billing codes on its own?
- Not reliably. When the prompt holds only the note, the model has no examples of a new code and no description of it. New codes go into a list outside the model, checked in plain code. People code the first cases, and the model learns the code at a later retraining.
- Is LoRA as good as full fine-tuning?
- Often it comes close, at a fraction of the cost. The original LoRA paper reported quality on par or better than full fine-tuning on the four model families it tested, on general language benchmarks. For a narrow task like coding, start with LoRA and move to full fine-tuning only if your own tests show a real gap.
- Why not just use a bigger general model with better prompts?
- Because a bigger model still lacks your team's habits, and a long prompt costs money on every request. In this case, adding rules to the prompt barely moved accuracy. A small fine-tuned model learned the habits from past notes and ran faster and cheaper.
- Can AI fully replace medical coders?
- Not on the evidence in this guide. The 2024 Mount Sinai study found general models often produced wrong or vague codes, and the 2023 review found trained coding models struggle with rare codes. A safer design lets the model handle easy notes while coders review unsure notes and suggested rare codes, and audit a sample of the rest.
Sources and further reading
- LoRA: Low-Rank Adaptation of Large Language Models (Hu et al., arXiv 2106.09685, 2021)
- Fine-Tuning or Retrieval? Comparing Knowledge Injection in LLMs (Ovadia et al., arXiv 2312.05934, 2023)
- Automated Medical Coding on MIMIC-III and MIMIC-IV: A Critical Review and Replicability Study (Edin et al., SIGIR 2023)
- Mount Sinai news release on the 2024 NEJM AI study of large models as medical coders (22 April 2024)
- World Health Organization: International Classification of Diseases (ICD), including ICD-11's effective date
- WHO ICD-10 Version 2019 browser, chapter entry for K80 (cholelithiasis)
- CDC National Center for Health Statistics: ICD-10-CM, the US adaptation of ICD-10
- PhysioNet: MIMIC-IV v2.2, de-identified records from Beth Israel Deaconess Medical Center