← Projects

VSee 2025

PypeDeid

Solo · VSee internship · shipped to clients

Web UI titled 'clinical-ensemble' in the pype-deid pipeline builder: a vertical stack of detector cards from Start to End — Regex Ner, Whitelist, Blacklist, Presidio Ner (model en_core_web_lg), Huggingface Ner (model openai-privacy-filter), and Llm Ner (model gpt-4o-mini) — each card with reorder, duplicate, and remove controls and plus-sign join points between stages.

The clinical-ensemble pipeline in the visual builder — six detectors stacked end-to-end, the whole thing serializable to one JSON file.

Where the data is

Hospitals and clinics are sitting on the largest collection of medical text that’s ever existed. If you wanted to train a real medical NLP model, license a dataset to a pharma research group, or just run internal analytics on your own patient population, that’s where the data is. HIPAA blocks all of it without de-identification first — the eighteen Safe Harbor categories have to come out before the corpus can leave the four walls of the provider. That gate is where I came in.

The service I was trying to replace

VSee had two things in production for de-id, and both of them were mine. A few-shot LLM prompt for the simple case, and a small dockerized microservice with a fine-tuned NER model behind a deid=ml | deid=llm switch on the client API. Clients used it, but the same two complaints kept coming back — to swap detection strategy you had to restart the service, and there was no way to give one client a tailored detector setup. I did some research, and the current state-of-the-art de-identification provider, John Snow Labs, uses a layered approach: regex, ML, and LLM together, with the overlaps resolved deliberately. Combined with the per-client pain in my own service, that pointed clearly at what to build next.

A different shape

I started over.

One engineer, about five weeks — a week on the pipe abstraction, then a month on the API and the two React UIs. Instead of a service with a strategy switch, a service where de-id is a pipeline — a sequence of small detectors, each doing one thing, stacked together however you want. A regex pipe catches the easy stuff (dates, phone numbers, MRNs). A whitelist pipe matches names against curated dictionaries. A Presidio pipe runs Microsoft’s NER. A HuggingFace pipe runs a fine-tuned BERT. An LLM pipe asks GPT to find what slipped through. After the detectors have all had a say, a span-resolution pipe merges the overlaps — strategies include union, consensus, max-confidence, and longest-non-overlapping; you pick the one that fits your risk posture.

The first time a single regex pipe ran end-to-end through POST /process/clinical-fast and returned spans in the right shape — just regex catching dates, nothing fancy — I knew the protocol was going to hold. The output wasn’t impressive. The surface contract was.

The whole pipeline serializes to one JSON file under data/pipelines/. Want to switch a client from “regex-only” to “regex + Presidio + LLM ensemble”? Change the pipeline name in their config. No restart.

{
  "pipes": [
    {"type": "regex_ner",        "config": {"pattern_pack": "clinical_phi", ...}},
    {"type": "whitelist",        "config": {"labels": {"NAME": {"terms": ["Sarah"], ...}}}},
    {"type": "blacklist",        "config": {"match": "any_token", ...}},
    {"type": "presidio_ner",     "config": {"model": "spacy/en_core_web_lg", ...}},
    {"type": "huggingface_ner",  "config": {"model": "openai-privacy-filter", ...}},
    {"type": "llm_ner",          "config": {"model": "gpt-4o-mini", "temperature": 0.0, ...}},
    {"type": "resolve_spans",    "config": {"strategy": "longest_non_overlapping"}}
  ],
  "description": "Clinical Ensemble — regex + whitelist + blacklist + Presidio + HuggingFace + LLM."
}

That’s the actual clinical-ensemble.json, abbreviated. The whole pipeline is one file. Git is the version control. There is no database.

A web UI with two panes. Left pane: a vertical flowchart of the clinical-ensemble pipeline (Regex → Whitelist → Blacklist → Presidio → HuggingFace → LLM). Right pane: the LLM detector's config form — model dropdown set to gpt-5.5, a labels list, a multi-line prompt template, a temperature slider.

The pipeline builder, with the LLM detector’s config open in the side panel. The form on the right wasn’t hand-built for the LLM pipe — it’s generated from the pipe’s config description.

The harder part is the part the screenshot doesn’t show. I knew I’d be adding pipes for years — every new model release, every new client request, every regulatory tweak, another pipe. The naive path was per-pipe-type forms in the frontend: each new pipe ships with a custom React component that knows about its config fields. That would have defeated the whole premise — now adding a detector is a backend change and a frontend change and a deploy, with the frontend maintainer as the bottleneck. So I set a constraint: adding a new pipe shouldn’t require touching the frontend at all.

The way that worked out is that every pipe describes its own configuration — what fields it takes, what types they are, what UI widgets they should render with. The admin app reads those descriptions on load and generates the forms automatically. A new pipe is now: write the config class, write the detector logic, register it with the system. Nothing else.

The contract that made that work is one sentence. The frontend knows about JSON Schema, not about pipes. Every other extensibility property fell out of that one constraint for free.

Video of a HuggingFace NER pipe getting its model swapped in the admin UI, with labels and hyperparameter context window refreshing live to match the new model's metadata.

The same contract, moving: change the HuggingFace model, the labels and context window refresh live from what the new model declares. No code change.

Dictionaries

Two of the pipes — whitelist and blacklist — don’t have models behind them. They have term lists. Whitelist gazetteers cover what you want to catch as PHI (first names, last names, hospitals, cities, insurance carriers). Blacklists cover what you want to not catch (clinical abbreviations, EHR boilerplate, medical phrases that would otherwise trigger false positives). Different pipelines pull different combinations from the same library.

Web form titled 'whitelist' (detector) showing WhitelistConfig fields: a NAME label toggle set to on with output mode 'keep as-is'; a Dictionaries multi-select listing female_first_names (4,235 terms, checked), insurance_carriers (81), last_names (945, checked), local_places_unambig_v2 (145), male-first-names (1,190, checked), pharmacies (45), us_cities (621), us_counties (196), us_hospitals (658), us_state_abbreviations (56), us_states (56), us_universities (309); an Inline Terms field containing the chip 'Sarah'; a LOCATION label toggle and 'Add custom label' input below.

Inside a whitelist pipe: three name dictionaries selected for the NAME label, plus an inline term added directly. Same idea as the model-backed pipes — pick what you want, the form renders itself.

Datasets

An eval is only as good as what you run it against. Datasets are the corpora pipelines run over — for evaluation or batch inference. Register a JSONL file, import a BRAT tree, or generate synthetic clinical notes via LLM with PHI extraction and span alignment so the generated data is gold-labeled from the start.

Datasets library view with the sample_notes corpus selected. The analytics dashboard shows summary cards (15 documents, 331 spans, 22.1 avg spans/doc, 0 overlapping docs), a distributions table for character length, token count, spans-per-doc and span length, and a color-coded label-distribution bar chart — NAME 92, DATE 83, LOCATION 40, ORGANIZATION 33, PHONE 30, AGE 23, ID 21, EMAIL 8, HOSPITAL 1 — beside a span-length histogram.

Corpus-level analytics for a registered dataset. The label-distribution chart, color-coded by semantic family, makes the corpus’s label balance obvious — and corpus issues show up here before they ever look like model issues.

Evaluation

Evaluation is how you decide whether a pipeline is good enough to deploy and where it falls short if it isn’t. Standard NER metrics lie about de-identification — missing one phone number in one note matters much more than missing a non-identifying date in fifty notes. The engine reports four matching modes side by side (strict, exact-boundary, partial-overlap, token-level BIO), risk-weighted recall using HIPAA severity weights, HIPAA Safe Harbor 18-identifier coverage, and per-label breakdown.

Evaluation results dashboard for the clinical-fast pipeline on the sample_notes dataset. Per-mode metric cards (strict, exact-boundary, partial-overlap, token-level P/R/F1), risk-weighted recall and macro F1, then two charts: an F1-by-label bar chart color-graded by score (EMAIL/PHONE/DATE strong in green, ORGANIZATION/LOCATION weak in red) and a precision-vs-recall bubble scatter where NAME sits at the bottom-left — 0% recall despite having the largest support — and a per-label table below.

The precision/recall scatter puts it in one glance: NAME is the biggest bubble (most support) yet sits at 0% recall. That isn’t a model failure — it’s the engine catching a label-space mismatch between gold corpus and pipeline output, exactly the kind of silent issue that tanks F1 if you’re not looking for it.

Interactive label-confusion heatmap. Rows are gold labels, columns are predicted; the green diagonal marks correct predictions (DATE, LOCATION, PHONE, AGE, ID, EMAIL) and red off-diagonal cells mark confusions. The dedicated MISSED column is dark red for NAME — every gold person name went undetected by the regex-only pipeline.

The same failure from another angle: hovering any cell traces a gold→predicted flow, and the MISSED column shows exactly which gold labels the pipeline never proposes — here, all of NAME.

Per-document inspection panel. A worst-F1-first list of documents on the left (each with F1 and false-positive/false-negative badges); on the right the selected note rendered twice — gold annotations vs. pipeline predictions — with true positives in green, missed spans in amber, and false positives in red. Person names like "Dmitri Voloshyn" and "Sandra Olufemi" are amber in the gold pane, caught nowhere in the predictions.

Drilling into a single document: structured identifiers (MRN, dates) come back green, but every person name is amber — missed. The aggregate metrics told you NAME recall was 0%; this tells you exactly which spans, in which note.

The point isn’t to score one pipeline. It’s to compare. Same dataset, two pipelines, four matching modes: you find out which is production-ready, which needs more work, and which labels are the weak ones.

Stable names, moving pipelines

Clients don’t talk to specific pipelines. They talk to modes — short aliases like fast or ensemble that map to a saved pipeline file on disk. A client integrates against POST /process/fast once and stops thinking about it. Internally, that alias points to a pipeline JSON. The operator can repoint fast to a different pipeline whenever they want — swap fast from clinical-fast to clinical-ensemble, edit the underlying pipeline JSON in place, or deprecate the old pipeline entirely and route the alias to its replacement. The client’s URL doesn’t change. Their integration code doesn’t change. The behavior behind the endpoint changes underneath them without them knowing.

Deploy configuration panel listing seven mode aliases (fast, presidio, transformer, transformer_presidio, llm, llm_presidio, ensemble), each with a Pipeline dropdown and a description. The "fast" mode is marked Default.

Each mode is a stable client-facing name; the dropdown beside it is what the operator changes when the underlying pipeline needs to change.

Picking a mode is also picking a latency profile, and the inference view makes that tradeoff legible: every run renders an execution waterfall showing where the request’s time actually went, pipe by pipe.

Inference view after running a Presidio-backed pipeline. An execution-timeline bar breaks the total runtime into per-pipe slices, color-coded; the PresidioNER stage fills almost the entire bar and is flagged as the bottleneck at 99%, while the regex and list pipes are slivers. Below, each trace step is listed with its own elapsed time and span delta.

The same composition that buys flexibility has a cost surface. A regex-only fast mode returns in microseconds; add an ML or LLM stage and that one pipe dominates the budget — the waterfall calls out the bottleneck automatically, so the accuracy/latency tradeoff behind each mode is a thing you can see, not guess.

It’s the same indirection CDN providers play with DNS, or feature flags play with code paths. The thing the consumer holds (an alias) is decoupled from the thing the operator edits (the implementation). For a service where different clients want different detector mixes, where regulatory requirements shift, where new models keep coming out — that decoupling is the difference between an integration that survives change and one that breaks every time the system improves.

One production-posture choice worth mentioning: the service refuses to start without explicit configuration. Two environment flags — one for “I really do want to run with auth disabled” and one for “I really do want to let the LLM pipe call out to an external API with PHI in the prompt.” Both default to unset, and the app won’t come up without them being explicitly set to true. You can’t accidentally run an insecure deployment; you have to consciously choose to.

The thing I’m proudest of isn’t on any feature list

PypeDeid has two kinds of users. Operators who edit pipelines, register datasets, tune deploy config. And consumers who call the API or review output. They need different levels of authority, and the design that fell out of that is what I’m most happy with.

There’s a dual API key model. An admin key gates configuration routes — pipeline CRUD, dataset registration, deploy config, dictionaries. An inference key opens only POST /process/*, a health endpoint, and read-only audit. Two React apps, both thin over the same FastAPI backend: a Playground that ships an admin key, and a Production app that ships an inference key. The Production app doesn’t have routes for editing pipelines, but more importantly the backend would reject the request anyway. A client integrating against PypeDeid can ship the Production app to their reviewers and know it physically cannot modify the configuration their pipelines are running against.

What that scoping enables is the Production UI’s human-in-the-loop dataset workflow. A reviewer loads a batch of clinical notes, picks a deployed mode (fast, ensemble, whatever fits), runs detection across the batch, and then goes through note by note accepting, rejecting, or correcting individual spans. Once spans are right, applying the surrogate output mode replaces every detected identifier with a Faker-generated stand-in of the same label class — fake names, fake MRNs, fake dates that look real but aren’t.

Side-by-side panels showing a synthetic discharge summary in the Production UI. Left panel: original text with highlighted spans color-coded by label (NAME, ID, DATE, AGE, HOSPITAL, EMAIL). Right panel: the same document with each highlighted span replaced by a Faker-generated surrogate of the same label.

Inside the Production UI: original synthetic note with PHI spans highlighted (left), surrogate-replaced version (right). When the spans are right, what comes out is a labeled, de-identifiable note — and a batch of these is a clinical NER dataset that’s safe to share.

That’s what makes this system useful past one-off redaction. A provider sitting on a corpus of clinical notes can run them through a deployed pipeline, have reviewers with an inference key clean up the detection output, and walk away with a labeled NER dataset safe to ship — to a vendor, to a research partner, or to their own model-training pipeline. The pipeline isn’t just a redaction service; it’s the front half of a dataset-manufacturing workflow.

What I learned

The system’s behavior is whatever data it finds at startup. Pipelines, dictionaries, models, and deploy modes all live on disk, and the application reads them. Adding capability is editing data, not writing code. That’s the property that keeps a system useful without its author in the loop — and it’s the property the codebase is being tested on right now, since I’m still adding HuggingFace models to a frozen repo without touching any of it.

macOS Finder showing the models/huggingface/ directory with seven model subdirectories. The "openai-privacy-filter" row is highlighted in blue, modified today; the others are older fine-tuned BERTs dated two weeks earlier.

Adding a new model is a filesystem operation. The highlighted row is OpenAI’s open-source 1B-parameter privacy filter, dropped in next to my older fine-tuned BERTs.

The deeper version of the same lesson is one I only got to by shipping its opposite first. The old deid=ml | deid=llm switch was inheritance-shaped — one service with alternate code paths baked inside. Replacing it with a tiny protocol and a sequence of small composable parts is what made every other extensibility property fall out for free. Composition over inheritance, learned the hard way, by deploying the inheritance version into production and watching it get stuck.