Cookbook

LLM fine-tuning on sensitive data

Fine-tune a small model on PHI-laden clinical notes without a single real identity reaching the model weights — synthesize the PII with Tonic Textual, train and serve on Fireworks, and measure exactly what de-identification costs. (Spoiler: nothing.)

Updated July, 2026
0 min read
0 real identities left in the training set after synthesis — audited, not assumed
99.3% schema-valid extractions from the model trained only on de-identified notes
3B parameters doing a frontier model's extraction job at a fraction of the cost

The problem

Fine-tuning on sensitive data presents challenges beyond model convergence. Train on unredacted PII or PHI and that information is potentially memorized by the model: the weights themselves become a sensitive artifact, constraining where the model can be deployed, who can query it, and which vendors can ever touch it.

The naive fix — stripping PII out of the data — often destroys the very signal you're fine-tuning for. If the model's job is to find and extract patient names, dates, and demographics, training notes full of [REDACTED] tokens teach it nothing.

The ideal is to redact & synthesize: swap every name, date, address, and identifier for a realistic fake value that stays consistent within each record, so the data keeps its utility while the identities disappear.

The approach: synthesize, then prove it

Tonic Textual detects PII/PHI spans with proprietary NER models and replaces them with realistic synthetic values. Our example task is structured extraction: turning free-text encounter notes into typed records with HL7-aligned value sets. Frontier models can zero-shot this, but at production scale, frontier prices are prohibitive — a small fine-tuned model delivers the same structured output far cheaper.

Does synthesis destroy the training signal? That's an empirical question, so this playbook measures it. We fine-tune the same base model twice on Fireworks — once on synthesized notes, once on the originals — and evaluate both, plus the un-tuned base model, on real held-out notes:

The three contenders we fine-tune and evaluate on real held-out notes: the un-tuned base model, a model fine-tuned on synthesized notes, and one fine-tuned on the original notes.
Contender Trains on What it shows
Base model, zero-shot Why fine-tune at all
Fine-tuned on synthetic De-identified notes The safe pipeline
Fine-tuned on real Original notes What synthesis costs us

If the last two rows come out close, de-identification is a free lunch.

About the data
Real clinical notes can't be published, so this playbook uses TonicAI/synthetic_clinical_notes and role-plays it as PHI. It was built from Synthea synthetic patient records, with notes generated by a modified chatty-notes. Each row pairs a free-text note with ground-truth structured encounter_data.

Before you run it

Cost note: the two fine-tuning jobs bill per training token — a few dollars at these defaults, plus 10–20 minutes of waiting — and the evaluation uses a dedicated GPU deployment billed per GPU-second while it exists (step 6 tears it down).

%pip install -q --pre fireworks-ai tonic-textual datasets pandas tqdm

from fireworks import Fireworks, NotFoundError
from tonic_textual.redact_api import TextualNer
from tonic_textual.classes.generator_metadata.name_generator_metadata import NameGeneratorMetadata

textual = TextualNer(api_key=os.environ["TONIC_TEXTUAL_API_KEY"])
fw = Fireworks(account_id=os.environ["FIREWORKS_ACCOUNT_ID"])

BASE_MODEL = "accounts/fireworks/models/llama-v3p2-3b-instruct"

Cookbook steps

1

Load the clinical notes

The dataset has 1,028 training notes, 672 validation, 1,681 test. Each record is a free-text encounter note paired with the structured fields we'll teach the model to extract.

from datasets import load_dataset

ds = load_dataset("TonicAI/synthetic_clinical_notes")
example = ds["train"][0]
DatasetDict({
    train:      1028 rows  ['note', 'encounter_data']
    validation:  672 rows
    test:       1681 rows
})
2

De-identify with Tonic Textual

Textual detects PHI spans — names, dates, addresses, phone numbers, IDs — and replaces them. Two configuration choices matter for fine-tuning:

  • Synthesis, not redaction. Replacing “Harvey D'Amore” with a [NAME_GIVEN] token would leave unnatural text and destroy the extraction labels. Synthesis swaps in a realistic substitute, so the notes still read like notes and the task is unchanged.
  • Consistency between note and labels. Each {note, encounter_data} record goes through redact_json as a single unit with a fixed per-record seed, so the same synthetic name lands in the note text and in the ground-truth JSON. The input→output mapping the model has to learn survives de-identification intact.

Everything that isn't PHI — symptoms, procedures, medications, clinical reasoning — passes through untouched.

These are the entity types synthesized in this playbook (Textual detects many more — see the full entity library):

NAME_GIVEN NAME_FAMILY LOCATION_ADDRESS LOCATION_CITY LOCATION_STATE LOCATION_ZIP LOCATION_COUNTRY PHONE_NUMBER EMAIL_ADDRESS CREDIT_CARD CC_EXP CVV MONEY ORGANIZATION DOB DATE_TIME URL NUMERIC_PII HEALTHCARE_ID
PHI_LABELS = [
    "NAME_GIVEN", "NAME_FAMILY", "LOCATION_ADDRESS", "LOCATION_STATE",
    "LOCATION_CITY", "LOCATION_ZIP", "LOCATION_COUNTRY", "PHONE_NUMBER",
    "EMAIL_ADDRESS", "CREDIT_CARD", "CC_EXP", "CVV", "MONEY", "ORGANIZATION",
    "DOB", "DATE_TIME", "URL", "NUMERIC_PII", "HEALTHCARE_ID",
]

DEID_CONFIG = dict(
    generator_config={label: "Synthesis" for label in PHI_LABELS},
    # keep synthetic given names gender-consistent — gender is an extraction target
    generator_metadata={"NAME_GIVEN": NameGeneratorMetadata(preserve_gender=True)},
    generator_default="Off",
)

response = textual.redact_json(example, random_seed=42, **DEID_CONFIG)
synthetic_example = json.loads(response.redacted_text)

One call, one record — here's what changes and what doesn't:

Original note contains PHI
Patient Name: Harvey D'Amore Date of Birth: September 12, 1964 MRN: HD-AM-0901964 Date of Encounter: October 18, 2023 Attending Dentist: Dr. Emily Thompson Location: Greenfield Dental Clinic, 123 Oak Street, Springfield, IL
Mr. Harvey D'Amore presents for a routine check-up and management of gingivitis…
Synthesized note Zero PHI
Patient Name: Kilby Orduna Date of Birth: September 14, 1964 MRN: UR-JX-5085941 Date of Encounter: October 15, 2023 Attending Dentist: Dr. Madalene Lenhoff Location: Gold Spur Systems, 341 Suburban Track, Springfield, IL
Mr. Kilby Orduna presents for a routine check-up and management of gingivitis…
The clinical content — gingivitis, the check-up, every symptom and procedure — is untouched. Only the identity moved. The same swap lands in the ground-truth labels, so encounter_data.name now reads "Kilby Orduna" too.

Now the full training split, with a distinct seed per record — synthetic identities vary across the dataset but stay consistent within each record. Only the training split gets de-identified, because it's the thing that leaves the compliance boundary; evaluation happens later against the real validation notes, which stay in your environment.

def synthesize_record(args):
    i, record = args
    response = textual.redact_json(record, random_seed=i, **DEID_CONFIG)
    synthetic = json.loads(response.redacted_text)
    # redact_json drops empty fields ([] / null); restore them so the
    # training labels keep the full schema — empty values carry no PHI
    for key, value in record["encounter_data"].items():
        if key not in synthetic["encounter_data"]:
            assert not value, f"non-empty field {key!r} went missing"
            synthetic["encounter_data"][key] = value
    return synthetic

with cf.ThreadPoolExecutor(max_workers=6) as ex:
    synthetic_train = list(tqdm(
        ex.map(synthesize_record, enumerate(ds["train"])),
        total=len(ds["train"]),
    ))

A quick audit before we call this safe: did any original identity survive into its synthetic counterpart?

original_names = [r["encounter_data"]["name"] for r in ds["train"]]
synthetic_names = [r["encounter_data"]["name"] for r in synthetic_train]

survivors = [
    orig for orig, synth in zip(original_names, synthetic_train)
    if orig in synth["note"] or orig == synth["encounter_data"]["name"]
]

print("records where the original name survived:", len(survivors))
print("full-name overlap between real and synthetic sets:",
      len(set(original_names) & set(synthetic_names)))
      
records where the original name survived: 0
full-name overlap between real and synthetic sets: 0
3

Define the extraction task

The target is one typed record per encounter. race and gender are constrained to HL7 value sets (US Core Race, Gender Identity); the remaining fields are free-form but formatted per rules in the system prompt. This isn't a full FHIR resource — it's the structured, vocabulary-controlled layer you'd map into one.

The Pydantic model does double duty: it documents the schema inside the system prompt, and at evaluation time it validates model outputs.

class GenderIdentityEnum(str, Enum):
    """HL7 THO 'Gender Identity' value set."""
    FEMALE = "female"
    MALE = "male"
    NON_BINARY = "non-binary"
    ASKED_DECLINED = "asked-declined"
    UNKNOWN = "unknown"

class FhirUsCoreRaceEnum(str, Enum):
    """HL7 'US Core Race' value set."""
    WHITE = "white"
    ASIAN = "asian"
    AFRICAN_AMERICAN = "black or african american"
    NATIVE_HAWAIIAN = "native hawaiian or other pacific islander"
    AMERICAN_INDIAN = "american indian or alaska native"
    UNKNOWN = "unknown"

class EncounterData(BaseModel):
    name_given: str              # patient's first given name
    name_family: str             # patient's family name (surname)
    age: int                     # age in years at encounter
    race: FhirUsCoreRaceEnum
    gender: GenderIdentityEnum
    reason: Optional[str]        # short diagnosis/complaint, title case
    procedures: List[str]        # distinct procedures, sentence case
    medications: List[str]       # generic drug names, [] if none
    encounter_type: str          # e.g. "encounter for check up"

Each record becomes a chat conversation — Fireworks fine-tunes on JSONL where every line is {"messages": [...]}, the same shape as a chat-completion request. The assistant target is projected through the schema, so the labels contain exactly the fields the model must produce. One file per training arm.

def to_messages(record):
    target = EncounterData.model_validate(record["encounter_data"]).model_dump(mode="json")
    return {"messages": [
        {"role": "system", "content": SYSTEM_PROMPT},   # full prompt in the notebook
        {"role": "user", "content": record["note"]},
        {"role": "assistant", "content": json.dumps(target)},
    ]}

write_jsonl(DATA_DIR / "train_real.jsonl", ds["train"])
write_jsonl(DATA_DIR / "train_synthetic.jsonl", synthetic_train)
4

Fine-tune on Fireworks

Two identical LoRA jobs on Llama 3.2 3B Instruct, differing only in their training data. Worth stating plainly: in a real deployment, only train_synthetic.jsonl would ever be uploaded. The real training file goes up here purely to build the baseline that measures what synthesis costs.

real_dataset  = ensure_dataset("clinical-notes-train-real", DATA_DIR / "train_real.jsonl")
synth_dataset = ensure_dataset("clinical-notes-train-synthetic", DATA_DIR / "train_synthetic.jsonl")

def ensure_sft_job(job_id, dataset_name):
    try:
        return fw.supervised_fine_tuning_jobs.get(job_id)   # idempotent re-runs
    except NotFoundError:
        return fw.supervised_fine_tuning_jobs.create(
            supervised_fine_tuning_job_id=job_id,
            base_model=BASE_MODEL,
            dataset=dataset_name,
            output_model=f"accounts/{FIREWORKS_ACCOUNT}/models/{job_id}",
            epochs=2,
            lora_rank=8,
        )

ensure_sft_job("clinical-note-extractor-synthetic", synth_dataset)
ensure_sft_job("clinical-note-extractor-real", real_dataset)

clinical-note-extractor-synthetic: JOB_STATE_COMPLETED
clinical-note-extractor-real:      JOB_STATE_COMPLETED

The jobs run concurrently and finish in roughly 10–20 minutes at this dataset size. Progress, loss curves, and sample renders are visible in the Fireworks dashboard.

5

Deploy and evaluate

Time to measure. The evaluation set is the real validation notes — real notes are what the model faces in production, and the synthetic-trained model has never seen one.

One dedicated deployment serves all three contenders: a single deployment of the base model with LoRA add-ons enabled hosts both adapters, and requests route per call via the #deployment suffix. It bills per GPU-second while it exists — step 6 tears it down.

deployment = fw.deployments.create(
    deployment_id="clinical-note-extractor",
    base_model=BASE_MODEL,
    enable_addons=True,
    accelerator_type="NVIDIA_B200_180GB",  # fits the 3B base + LoRA add-ons easily
)

fw.lora.load(model=SYNTH_MODEL, deployment=deployment.name)
fw.lora.load(model=REAL_MODEL, deployment=deployment.name)

CONTENDERS = {
    "base (zero-shot)":        f"{BASE_MODEL}#{deployment.name}",
    "fine-tuned on synthetic": f"{SYNTH_MODEL}#{deployment.name}",
    "fine-tuned on real":      f"{REAL_MODEL}#{deployment.name}",
}

Scoring is strict: outputs must validate against the Pydantic schema (a failure zeroes every field for that record), scalar fields score exact-match after normalization, and list fields (procedures, medications) score set-F1.

def score_one(model_ref, record):
    true = record["encounter_data"]
    try:
        pred = EncounterData.model_validate(extract(model_ref, record["note"]))
        pred = pred.model_dump(mode="json")
    except Exception:
        return {"schema-valid": 0.0, **{f: 0.0 for f in SCALAR_FIELDS + LIST_FIELDS}}
    row = {"schema-valid": 1.0}
    for field in SCALAR_FIELDS:
        row[field] = float(norm(pred.get(field)) == norm(true[field]))
    for field in LIST_FIELDS:
        row[field] = set_f1(pred.get(field) or [], true[field])
    return row

Extraction accuracy on 150 real held-out notes

Mean per-field score (exact match; set-F1 for list fields) · higher is better

Fine-tuned on synthetic Fine-tuned on real Base, zero-shot
0 0.25 0.5 0.75 1.0

Reading the results

Two comparisons matter, and the chart settles both:

  • Base vs. either fine-tune — the case for fine-tuning. The un-tuned 3B model produces schema-valid JSON less than a third of the time and lands near zero on the formatting-sensitive fields (encounter_type 0.09, medications 0.12). Both fine-tunes clear 97% schema validity and 0.86 on every field.
  • Synthetic vs. real — the point of this playbook. The synthetic-trained model matches the real-trained one everywhere; in this run it actually came out slightly ahead on most fields (0.99 vs 0.97 schema validity — differences of this size are within run-to-run noise). It extracts names at 0.99 despite having been trained entirely on invented identities: it learned where names live in a clinical note, not which names exist. De-identification cost nothing measurable.
The difference the chart can't show
The real-trained model was optimized to emit 1,028 actual patient identities — its weights are a PHI liability, in principle promptable into regurgitating real names. The synthetic-trained model cannot leak a real identity. It has never seen one.
6

Clean up

Delete the deployment to stop GPU billing (skip this if you're keeping the endpoint up for real traffic). The datasets and fine-tuned models remain in your account, ready to redeploy.

for deployed in fw.lora.list():
    if deployed.deployment == deployment.name:
        fw.lora.unload(deployed.name.split("/")[-1])

fw.deployments.delete(DEPLOYMENT_ID, ignore_checks=True)

deployment deleted — billing stopped

Takeaway

We fine-tuned a clinical-note extractor without any PHI leaving the compliance boundary: Tonic Textual synthesized the identities in the training notes (consistently within each record), and Fireworks trained both models and served them from a single multi-LoRA deployment. On real held-out notes, the synthetic-trained model performed on par with — this run, marginally better than — one trained on the original data, at more than triple the un-tuned base model's schema validity.

To adapt this to your own notes, swap in your data loader and revisit two knobs: PHI_LABELS (which entity types get synthesized) and the extraction schema/system prompt. Everything else — the consistency-preserving redact_json step, the two-arm training harness, the leakage audit, the evaluation — carries over unchanged.

Try it yourself

Notebook Run the full cookbook The end-to-end Jupyter notebook: de-identification, both fine-tuning arms, evaluation, and cleanup.
Dataset TonicAI/synthetic_clinical_notes 2,300+ Synthea-derived encounter notes with ground-truth structured labels, on Hugging Face.
Docs Tonic Textual documentation The full entity library, generator options, and SDK reference behind step 2.
Source for the dataset methodology: Jason Walonoski et al., Synthea: An approach, method, and software mechanism for generating synthetic patients and the synthetic electronic health care record, JAMIA 25(3), 2018. doi:10.1093/jamia/ocx079