This notebook is a complete, stage-by-stage showcase of the Document Image Classification and Information Extraction (DICIE) chain implemented in src/docie/.
It follows Fig. 1 from Raj, Dickinson & Fung, Document Classification and Information Extraction framework for Insurance Applications:
No API keys required. The default path uses keyword heuristics + regex extraction.
OCR (pip install -e ".[ocr]" + system Tesseract) is optional; this notebook defaults to RUN_OCR = False so it runs cleanly on CPU without Tesseract.
{"application":"salvage_claims","description":"Classify bank-issued letters of guarantee and related salvage attachments, then extract vehicle identifiers for total-loss automobile claims.\n","categories":[{"label":"log","aliases":["LOG","Letter of Guarantee","Letter Of Guarantee","Guarantee"],"description":"Bank-issued letter of guarantee for loan/lease payoff on a total-loss vehicle."},{"label":"sales","aliases":["SALES","Sales Receipt","Sales Tax","Bill of Sale"],"description":"Salvage sales receipt or sales-tax documentation."},{"label":"other","aliases":["OTHER","Salvage Other"],"description":"Other salvage-claim attachments that are neither LOG nor sales receipt."}],"extraction_fields":["claim_id","vin","year","make","model"],"business_rules":{"prefer_non_other":true,"review_confidence_threshold":0.55}}
2. Fixture corpus
tests/fixtures/sample_docie_documents.jsonl ships six labeled text documents — three medical, three salvage — covering the positive classes and an other negative for each application.
fixture_rows = load_jsonl(FIXTURES)fixture_df = pd.DataFrame( [ {"record_id": r["record_id"],"application": r["application"],"document_type": r["document_type"],"chars": len(r["text"]),"preview": r["text"].splitlines()[0][:60], }for r in fixture_rows ])display(fixture_df)by_id = {r["record_id"]: r for r in fixture_rows}LOG_TEXT = by_id["sal-log-001"]["text"]HCFA_TEXT = by_id["med-hcfa-001"]["text"]display(Markdown("### Sample — salvage Letter of Guarantee"))print(LOG_TEXT)display(Markdown("### Sample — medical HCFA / CMS-1500"))print(HCFA_TEXT)
record_id
application
document_type
chars
preview
0
med-hcfa-001
medical_bills
hcfa
292
HCFA CMS-1500 HEALTH INSURANCE CLAIM FORM
1
med-ub04-002
medical_bills
ub04
226
UB-04 UNIFORM BILLING FORM CMS-1450
2
med-other-003
medical_bills
other
155
COMMUNITY CLINIC STATEMENT
3
sal-log-001
salvage_claims
log
253
LETTER OF GUARANTEE
4
sal-sales-002
salvage_claims
sales
203
SALVAGE SALES RECEIPT
5
sal-other-003
salvage_claims
other
120
TOWING INVOICE
Sample — salvage Letter of Guarantee
LETTER OF GUARANTEE
First National Bank Lienholder Services
This letter guarantees that the insurer reimbursement will pay the bank first.
Claim Number: CLM-2024-100200
VIN: 1HGCM82633A004352
Year: 2018
Make: Honda
Model: Accord
Payoff Amount: $4,250.00
Sample — medical HCFA / CMS-1500
HCFA CMS-1500 HEALTH INSURANCE CLAIM FORM
Physician or Supplier Information
Patient Name: Jane Q Public
Date of Birth: 03/14/1988
Patient ID: PID-778812
Claim Number: CLM-2024-551122
Address: 100 Oak Avenue, Madison WI 53703
Carrier Name: American Family
Diagnosis and procedure codes follow.
3. Stage 1 — Document Processing (text input)
process_document_input is the image-first entry point:
Accept PDF, image, plain text, or empty
Render / preprocess to grayscale page PNG(s) under a cache directory
Optionally OCR (PyTesseract) → word tokens with 0–1000 normalized boxes
Return a ProcessedDocument ready for classify + extract
Text inputs are rendered to a page image so the rest of the chain stays image-first (paper §V.C).
record_id: walkthrough-log
source_kind: text
n_pages: 1
application: salvage_claims
--- full_text ---
LETTER OF GUARANTEE
First National Bank Lienholder Services
This letter guarantees that the insurer reimbursement will pay the bank first.
Claim Number: CLM-2024-100200
VIN: 1HGCM82633A004352
Year: 2018
Make: Honda
Model: Accord
Payoff Amount: $4,250.00
{"page_index":0,"image_path":"/Users/morningstar/Desktop/Cold_Storage/smol-doc-analyzer/data/notebook_demo/docie/cache/pages/walkthrough-log__5750df1a2104a5cc__page_000.png","width":1000,"height":1200,"dpi":200,"grayscale":true,"ocr_backend":"rendered_text","n_words":34,"text_preview":"LETTER OF GUARANTEE\nFirst National Bank Lienholder Services\nThis letter guarantees that the insurer reimbursement will pay the bank first.\nClaim Number: CLM-2024-100200\nVIN: 1HGCM82633A004352\nYear: 20"}
4. Stage 1 — Document Processing (PDF input)
PDFs are rasterized with PyMuPDF at the configured DPI. Even with run_ocr=False, the pipeline can fall back to the PDF text layer (pymupdf_text) when present — useful for born-digital forms.
from reportlab.pdfgen import canvaspdf_path = DEMO /"sample_letter_of_guarantee.pdf"c = canvas.Canvas(str(pdf_path))c.setFont("Helvetica-Bold", 14)c.drawString(72, 720, "LETTER OF GUARANTEE")c.setFont("Helvetica", 11)lines = ["First National Bank Lienholder Services","This letter guarantees that the insurer reimbursement will pay the bank first.","Claim Number: CLM-PDF-2024-77","VIN: 1HGCM82633A004352","Year: 2019","Make: Toyota","Model: Camry","Payoff Amount: $3,100.00",]y =690for line in lines: c.drawString(72, y, line) y -=18c.showPage()c.setFont("Helvetica", 11)c.drawString(72, 720, "Page 2 — supporting payoff schedule (attachment).")c.drawString(72, 700, "Account Ref: BANK-998877")c.save()print(f"wrote {pdf_path}")processed_pdf = process_document_input( record_id="walkthrough-pdf", pdf_path=pdf_path, cache_dir=CACHE /"pages", application="salvage_claims", dpi=DPI, run_ocr=RUN_OCR,)print(f"source_kind: {processed_pdf.source_kind}")print(f"n_pages: {len(processed_pdf.pages)}")for p in processed_pdf.pages: show_page_image(p, title=f"PDF page {p.page_index}")print(f" ocr_backend={p.ocr_backend!r} text_chars={len(p.text)}")display(Markdown("### PDF full_text (text-layer / OCR)"))print(processed_pdf.full_text)
wrote /Users/morningstar/Desktop/Cold_Storage/smol-doc-analyzer/data/notebook_demo/docie/sample_letter_of_guarantee.pdf
source_kind: pdf
n_pages: 2
PDF page 0 — walkthrough-pdf__af5ff734cb6ce3d7__page_000.png (1654×2339, dpi=200, ocr=pymupdf_text)
ocr_backend='pymupdf_text' text_chars=253
PDF page 1 — walkthrough-pdf__af5ff734cb6ce3d7__page_001.png (1654×2339, dpi=200, ocr=pymupdf_text)
ocr_backend='pymupdf_text' text_chars=74
PDF full_text (text-layer / OCR)
LETTER OF GUARANTEE
First National Bank Lienholder Services
This letter guarantees that the insurer reimbursement will pay the bank first.
Claim Number: CLM-PDF-2024-77
VIN: 1HGCM82633A004352
Year: 2019
Make: Toyota
Model: Camry
Payoff Amount: $3,100.00
Page 2 — supporting payoff schedule (attachment).
Account Ref: BANK-998877
5. Stage 2 — Document Classification
Classification is page-first, then aggregated:
classify_page_text — keyword / alias scoring per class (backend heuristic_text)
Optional ViT when vit_model_dir has matching labels (blended 50/50 with text)
Paper business rule (medical / salvage): when page votes tie between other and a concrete class, prefer the concrete class. Soft preference also applies when a non-other score is close to other.
tie_pages = [ PageClassification(0, "other", 0.5, "heuristic_text", scores={"other": 0.5, "log": 0.0, "sales": 0.0}), PageClassification(1, "log", 0.5, "heuristic_text", scores={"log": 0.5, "other": 0.0, "sales": 0.0}),]tied = aggregate_page_predictions(tie_pages, salvage)display(Markdown(f"Tied pages `other` vs `log` (both conf=0.5) → **`{tied.label}`** "f"(aggregation=`{tied.aggregation}`, flags={tied.flags})"))show_json(tied, title="aggregate_page_predictions on tie")assert tied.label =="log", "prefer_non_other should break the tie toward log"
Tied pages other vs log (both conf=0.5) → log (aggregation=confidence_weighted_majority, flags=[‘low_confidence_classification’])
Extraction is conditioned on the Stage 2 document type:
Regex / pattern heuristics (heuristic_extract) cover medical + salvage fields
Optional LayoutLM token classifier when models/extractor* weights exist
Missing expected fields (e.g. LOG without VIN, HCFA without name) lower confidence and set review flags
Field patterns live in src/docie/extract.py (FIELD_PATTERNS).
display(Markdown("### Heuristic extract — salvage LOG fields"))salvage_fields = heuristic_extract(LOG_TEXT, salvage.extraction_fields)display(pd.DataFrame( [{"field": k, "values": v, "primary": v[0] if v elseNone} for k, v in salvage_fields.items()]))medical = load_application("medical_bills")display(Markdown("### Heuristic extract — medical HCFA fields"))medical_fields = heuristic_extract(HCFA_TEXT, medical.extraction_fields)display(pd.DataFrame( [{"field": k, "values": v, "primary": v[0] if v elseNone} for k, v in medical_fields.items()]))
Heuristic extract — salvage LOG fields
field
values
primary
0
claim_id
[CLM-2024-100200]
CLM-2024-100200
1
vin
[1HGCM82633A004352]
1HGCM82633A004352
2
year
[2018]
2018
3
make
[Honda]
Honda
4
model
[Accord]
Accord
Heuristic extract — medical HCFA fields
field
values
primary
0
claim_id
[CLM-2024-551122]
CLM-2024-551122
1
name
[Jane Q Public]
Jane Q Public
2
dob
[03/14/1988]
03/14/1988
3
patient_id
[PID-778812]
PID-778812
4
address
[100 Oak Avenue, Madison WI 53703]
100 Oak Avenue, Madison WI 53703
Full Stage 3 API — extract_information
Pass the ProcessedDocument + predicted document_type so missing-field flags are application-aware.
log_classification = classify_document(processed_log, salvage)log_extraction = extract_information( processed_log, salvage, document_type=log_classification.label,)show_json(log_extraction, title="ExtractionResult (LOG)")print(f"backend={log_extraction.backend} "f"document_type={log_extraction.document_type} "f"confidence={log_extraction.confidence:.3f} "f"flags={log_extraction.flags}")# Contrast: a sparse "other" document should extract little and may flag review laterother_text = by_id["sal-other-003"]["text"]processed_other = process_document_input( record_id="walkthrough-other", text=other_text, cache_dir=CACHE /"pages", application="salvage_claims", run_ocr=False,)other_cls = classify_document(processed_other, salvage)other_ext = extract_information( processed_other, salvage, document_type=other_cls.label)display(Markdown(f"### Sparse towing invoice → class=`{other_cls.label}` "f"(conf={other_cls.confidence:.3f}), fields={other_ext.fields_flat}"))
{"record_id":"e2e-hcfa","application":"medical_bills","document_type":"hcfa","classification_confidence":0.975,"fields":{"claim_id":"CLM-2024-551122","name":"Jane Q Public","dob":"03/14/1988","patient_id":"PID-778812","address":"100 Oak Avenue, Madison WI 53703"},"extraction_confidence":0.85,"needs_human_review":false,"flags":["extract_heuristic"]}
11. Batch processing — run_file
For JSONL corpora, run_file (also used by python -m src.docie --in …) writes:
The shared fixture mixes medical + salvage rows. Below we filter to the matching application before each batch so classification stays on-taxonomy (running salvage_claims over HCFA text would correctly fall through to other).
# Write application-filtered JSONL inputs for clean batch demossalvage_in = DEMO /"input_salvage.jsonl"medical_in = DEMO /"input_medical.jsonl"salvage_in.write_text("\n".join(json.dumps(r) for r in fixture_rows if r["application"] =="salvage_claims") +"\n", encoding="utf-8",)medical_in.write_text("\n".join(json.dumps(r) for r in fixture_rows if r["application"] =="medical_bills") +"\n", encoding="utf-8",)batch_out = DEMO /"batch_salvage.jsonl"path = run_file( salvage_in, batch_out, application="salvage_claims", cfg=cfg, run_ocr=RUN_OCR,)batch_rows = load_jsonl(path)summary = read_json(path.with_suffix(".summary.json"))review_path = path.with_name(path.stem +".human_review.jsonl")review_rows = load_jsonl(review_path) if review_path.exists() else []show_json(summary, title="salvage batch summary")batch_df = pd.DataFrame( [ {"record_id": r["record_id"],"document_type": r["document_type"],"cls_conf": round(r["classification"]["confidence"], 3),"ext_conf": round(r["extraction"]["confidence"], 3),"fields": {k: v for k, v in r["fields"].items() if v},"review": r["needs_human_review"],"flags": r["flags"], }for r in batch_rows ])display(batch_df)print(f"predictions: {path}")print(f"summary: {path.with_suffix('.summary.json')}")print(f"review queue ({len(review_rows)}): {review_path}")label_counts = Counter(r["document_type"] for r in batch_rows)fig, ax = plt.subplots(figsize=(5, 3))ax.bar(list(label_counts.keys()), list(label_counts.values()), color="#1b4965")ax.set_ylabel("count")ax.set_title("Batch label distribution (salvage_claims)")fig.tight_layout()plt.show()# Mirror for medical_billsmed_batch_out = DEMO /"batch_medical.jsonl"med_path = run_file( medical_in, med_batch_out, application="medical_bills", cfg=cfg, run_ocr=RUN_OCR,)med_summary = read_json(med_path.with_suffix(".summary.json"))show_json(med_summary, title="medical batch summary")med_batch_df = pd.DataFrame( [ {"record_id": r["record_id"],"document_type": r["document_type"],"cls_conf": round(r["classification"]["confidence"], 3),"fields": {k: v for k, v in r["fields"].items() if v},"review": r["needs_human_review"], }for r in load_jsonl(med_path) ])display(med_batch_df)
Pass a callable to DociePipeline(..., downstream_sink=fn). Every process call will invoke push_downstream, which delivers response_payload() to your webhook / queue / claim-center updater.
sunk: list[dict] = []pipe_with_sink = DociePipeline( application="salvage_claims", cfg=cfg, cache_dir=CACHE /"pipeline_sink", run_ocr=RUN_OCR, downstream_sink=sunk.append,)pred_sink = pipe_with_sink.process(record_id="sink-demo", text=LOG_TEXT)payload = push_downstream(pred_sink) # also returns the payload without a sinkprint(f"sink received {len(sunk)} payload(s)")show_json(sunk[0], title="Payload delivered to downstream_sink")assert sunk[0]["document_type"] =="log"assert payload["fields"]["vin"]
The cell below reports what is available locally (smoke weights from the other pipeline notebook do not match medical/salvage labels, so heuristics remain the active path).
candidates = [ cfg.models_dir /"extractor", cfg.models_dir /"extractor_smoke", REPO_ROOT /"data"/"notebook_demo"/"models"/"extractor_smoke", REPO_ROOT /"data"/"notebook_demo"/"models"/"classifier_smoke",]avail = []for path in candidates: cfg_json = path /"config.json" labels = path /"label2id.json" avail.append( {"path": str(path.relative_to(REPO_ROOT)) if path.is_relative_to(REPO_ROOT) elsestr(path),"exists": path.exists(),"has_config": cfg_json.exists(),"has_label2id": labels.exists(),"label_sample": (list(json.loads(labels.read_text()).keys())[:6] if labels.exists() else [] ), } )display(pd.DataFrame(avail))print("Default DociePipeline extractor_dir:", pipe_salvage.extractor_dir,)print("Heuristic path is sufficient for this walkthrough; ""point vit_model_dir / extractor_dir at matching weights to activate neural backends.")
path
exists
has_config
has_label2id
label_sample
0
models/extractor
False
False
False
[]
1
models/extractor_smoke
False
False
False
[]
2
data/notebook_demo/models/extractor_smoke
True
True
True
[O, B-claim_id, I-claim_id, B-policy_number, I...
3
data/notebook_demo/models/classifier_smoke
True
True
True
[application_commercial, application_personal,...
Default DociePipeline extractor_dir: None
Heuristic path is sufficient for this walkthrough; point vit_model_dir / extractor_dir at matching weights to activate neural backends.
Optional — FastAPI serve shape
Not started from this notebook (keeps the walkthrough offline), but the paper §VI serving surface is:
Ran Stage 1 on text (rendered page PNG) and a 2-page PDF
Classified pages with heuristics and aggregated with prefer-non-other
Extracted medical + salvage fields via heuristic_extract / extract_information
Aggregated into DociePrediction with review routing + dual payload shapes
Drove the full DociePipeline for salvage LOG and medical HCFA/UB04/other
Batched JSONL via run_file (predictions + summary + human-review queue)
Demonstrated a downstream sink and inspected optional model weight paths
All demo artifacts land under:
artifacts =sorted(p for p in DEMO.rglob("*") if p.is_file())art_df = pd.DataFrame( [ {"path": str(p.relative_to(REPO_ROOT)),"kb": round(p.stat().st_size /1024, 1), }for p in artifacts ])display(art_df)print(f"{len(art_df)} files under {DEMO.relative_to(REPO_ROOT)}")display(Markdown("### Next steps\n""- Point `vit_model_dir` / `extractor_dir` at application-matched weights\n""- Enable `RUN_OCR = True` for scanned page images\n""- Serve with `python -m src.docie.serve` for the REST surface\n""- Read `docs/docie_pipeline.md` for production CLI recipes"))
path
kb
0
data/notebook_demo/docie/batch_medical.human_r...
0.4
1
data/notebook_demo/docie/batch_medical.jsonl
4.4
2
data/notebook_demo/docie/batch_medical.summary...
0.4
3
data/notebook_demo/docie/batch_salvage.human_r...
0.4
4
data/notebook_demo/docie/batch_salvage.jsonl
4.1
5
data/notebook_demo/docie/batch_salvage.summary...
0.4
6
data/notebook_demo/docie/cache/pages/walkthrou...
18.2
7
data/notebook_demo/docie/cache/pages/walkthrou...
12.7
8
data/notebook_demo/docie/cache/pages/walkthrou...
71.4
9
data/notebook_demo/docie/cache/pages/walkthrou...
33.1
10
data/notebook_demo/docie/cache/pipeline_medica...
19.9
11
data/notebook_demo/docie/cache/pipeline_medica...
19.9
12
data/notebook_demo/docie/cache/pipeline_medica...
13.8
13
data/notebook_demo/docie/cache/pipeline_medica...
17.6
14
data/notebook_demo/docie/cache/pipeline_salvag...
71.4
15
data/notebook_demo/docie/cache/pipeline_salvag...
33.1
16
data/notebook_demo/docie/cache/pipeline_salvag...
18.2
17
data/notebook_demo/docie/cache/pipeline_sink/p...
18.2
18
data/notebook_demo/docie/e2e_salvage_log.json
1.9
19
data/notebook_demo/docie/input_medical.jsonl
1.0
20
data/notebook_demo/docie/input_salvage.jsonl
0.9
21
data/notebook_demo/docie/sample_letter_of_guar...
2.3
22 files under data/notebook_demo/docie
Next steps
Point vit_model_dir / extractor_dir at application-matched weights
Enable RUN_OCR = True for scanned page images
Serve with python -m src.docie.serve for the REST surface
Read docs/docie_pipeline.md for production CLI recipes