Log in
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.)
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.
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:
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-textnotewith ground-truth structuredencounter_data.
TONIC_TEXTUAL_API_KEY — from Tonic TextualFIREWORKS_API_KEY and your account id — from fireworks.aiCost 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"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
})Textual detects PHI spans — names, dates, addresses, phone numbers, IDs — and replaces them. Two configuration choices matter for fine-tuning:
[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.{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):
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:
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: 0The 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)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_COMPLETEDThe 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.
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 rowTwo comparisons matter, and the chart settles both:
encounter_type 0.09, medications 0.12). Both fine-tunes clear 97% schema validity and 0.86 on every field.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.
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 stoppedWe 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.