Graduate Quantitative Economics and Datascience
| Approach | Output | Main tradeoff |
|---|---|---|
| OCR specialist: Chandra | Document → HTML table | Native table output; limited instruction following |
| General vision-language model: Qwen or Claude | Image → requested JSON | Flexible; check guesses and omissions |
| Corpus fine-tune: adapted Chandra | Archive’s HTML / JSON | Learns local conventions; needs labeled pages |
| Split | What its labels are used for |
|---|---|
| Training | Update weights or LoRA parameters |
| Development / validation | Choose prompts, image size, training settings, and parsing/scoring rules |
| Test | Measure the frozen workflow on new documents |
| Example | Development or training | Evaluation shown here |
|---|---|---|
| Toronto | 1 development page; no training by us | 8 other writers, 103 labeled people |
| Paris | 2 development pages; no training by us | 4 other writers, 120 annotation rows |
| Argentina: seen page | Page used to train the published adapter | 15 people; training-page demonstration |
| Argentina: held-out page | Author’s blur-model test split | 15 people; author-selected best page, blurred input |
| Thai bill | Image from the published dataset’s train split | One crop; no held-out accuracy claim |
This material has not yet been fully vetted and checked.
import json
from pathlib import Path
import pandas as pd
from PIL import Image as PILImage
from IPython.display import Image, display, Markdown, HTML
ocr_dir = Path("data/handwriting")
toronto_dir = ocr_dir / "toronto"
toronto_sample = json.loads(
(toronto_dir / "scores/test_sample.json").read_text())
| Role | Pages / writers | Labeled people | Used for |
|---|---|---|---|
| Development | 1 / 1 | 26 | Design panels and prompts |
| Test | 8 / 8 | 103 | Compare with York records |

| Workflow | Local runtime | Output |
|---|---|---|
| Chandra OCR 2 | MLX, BF16 weights | Native OCR prompt → HTML |
| Qwen3.8 27B | Ollama MLX, MXFP8 | Fixed schema prompt → JSON |
| Qwen3.5 2B | Ollama, GGUF Q8_0 | The same JSON prompt |
From the repository root, install the optional packages on an Apple Silicon Mac:
Then run this Python code once:
from chandra.prompts import OCR_PROMPT
from mlx_vlm import load, generate
from mlx_vlm.prompt_utils import apply_chat_template
from mlx_vlm.utils import load_config
model_path = "build/handwriting/model"
image_path = "slides/data/handwriting/toronto/input_illustration.png"
model, processor = load(model_path)
prompt = apply_chat_template(
processor, load_config(model_path), OCR_PROMPT, num_images=1,
)
result = generate(
model, processor, prompt, image=[image_path],
max_tokens=4096, temperature=0.0, verbose=False,
)
html = result.text
print(html)model and processor for further imageshandwriting_mlx.py takes any local image path. The saved benchmarks use MLX’s server interface; the MLX Python interface exposes the same modelWith Ollama installed and running, choose a model:
Use the same instruction for both vision-language models:
prompt = (
'Extract every row of this census table as JSON. Return only an object '
'with key "rows", a list of objects with string fields "line", "name", '
'"age", "occupation", "earnings". Read only the image. Preserve spelling '
'and abbreviations, including ditto marks. Do not fill blanks or infer '
'missing values. Use an empty string for an empty cell. Keep every row in '
'printed order. No commentary.'
)qwen3-embeddingimport base64, json
from pathlib import Path
from urllib.request import Request, urlopen
image_path = Path("slides/data/handwriting/toronto/input_illustration.png")
encoded = base64.b64encode(image_path.read_bytes()).decode()
payload = {
"model": "qwen3.5:2b", "stream": False, "think": False,
"messages": [{"role": "user", "content": prompt, "images": [encoded]}],
"options": {"temperature": 0, "num_predict": 4096, "num_ctx": 16384},
"keep_alive": "10m",
}
request = Request("http://127.0.0.1:11434/api/chat",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urlopen(request, timeout=600) as response:
reply = json.load(response)
print(reply["message"]["content"])prompt is the preceding slide’s instruction. Change model to qwen3.8:27b-mxfp8 for the larger modelFrom the repository root, using the included Toronto panel:
--model qwen3.8:27b-mxfp8build_toronto_census.py downloads and prepares the panels; toronto_census_ocr.py runs them and saves responses, CSVs, and metadata. Full commandsdata/handwriting/. Model calls are displayed, not executed when rendering| line | name | age | occupation | earnings |
|---|---|---|---|---|
| 1 | Oldenburg John | 48 | Barge man | |
| 2 | do Sarah | 47 | ||
| 3 | do Mary | 20 |
"" means an extracted blank; NaN means unavailable output. Neither means zero earningspage_id and line join predictions to references. Names and occupations remain raw, unscored strings\[ \text{Digit accuracy} = 1 - \frac{\text{substitutions + deletions + insertions}} {\text{reference digits}} \]
53 → 23 has one error among two digits: 50%; 350 → 35 gives 66.7%NaN is excluded and reported in coveragetoronto_scores = pd.read_csv(toronto_dir / "scores/test_field_scores.csv")
toronto_models = {"chandra": "Chandra OCR 2",
"qwen27b": "Qwen 27B", "qwen2b": "Qwen 2B"}
toronto_table = pd.DataFrame(index=["age", "earnings"])
for model, name in toronto_models.items():
score = toronto_scores.loc[toronto_scores.workflow.eq(model)]
score = score.set_index("field")
toronto_table[name] = [
f"{r.digit_accuracy:.1%}" for r in score.loc[
toronto_table.index].itertuples()]
display(HTML(toronto_table.to_html()))| Chandra OCR 2 | Qwen 27B | Qwen 2B | |
|---|---|---|---|
| age | 77.4% | 79.0% | 74.2% |
| earnings | 82.5% | 83.7% | 81.4% |
toronto_structure = pd.read_csv(toronto_dir / "scores/test_structure.csv")
diagnostics = pd.DataFrame(index=list(toronto_models.values()))
diagnostics["Age cells available"] = [
f"{r.age_available}/{r.expected_cells_per_field}"
for r in toronto_structure.itertuples()]
diagnostics["Earnings cells available"] = [
f"{r.earnings_available}/{r.expected_cells_per_field}"
for r in toronto_structure.itertuples()]
earnings_rows = toronto_scores.loc[toronto_scores.field.eq("earnings")]
diagnostics["Blank earnings: matches / available"] = [
f"{r.blank_correct}/{r.blank_available}"
for r in earnings_rows.itertuples()]
display(HTML(diagnostics.to_html()))| Age cells available | Earnings cells available | Blank earnings: matches / available | |
|---|---|---|---|
| Chandra OCR 2 | 400/400 | 360/400 | 66/68 |
| Qwen 27B | 392/400 | 392/400 | 74/74 |
| Qwen 2B | 400/400 | 400/400 | 73/74 |
NaN, without rejecting the rest of the tableNaN. Qwen 27B’s eight unavailable rows have no York labelsChandra creates a two-column earnings header on the Forbes page:
Its row 6 contains two earnings cells:
NaN for earnings and retains name, age, and occupation. It does not guess which earnings subcolumn to useage="71", earnings=NaN. The age is scored independently| Disagreements | |
|---|---|
| Flagged reference | 3 |
| Hutchman page | 21 |
| Other digit disagreements | 11 |
toronto_earnings = pd.read_csv(toronto_dir / "scores/test_earnings.csv")
earnings_table = pd.DataFrame({
"Model": toronto_earnings.workflow.map(toronto_models),
"Numeric / labeled": [
f"{r.numeric_predictions}/{r.reference_annual_earners}"
for r in toronto_earnings.itertuples()],
"Paired reference mean":
toronto_earnings.paired_reference_mean,
"OCR mean": toronto_earnings.predicted_mean,
"Mean abs. error": toronto_earnings.paired_mean_absolute_error,
})
display(HTML(earnings_table.to_html(index=False, float_format="${:,.0f}".format)))| Model | Numeric / labeled | Paired reference mean | OCR mean | Mean abs. error |
|---|---|---|---|---|
| Chandra OCR 2 | 26/29 | $376 | $320 | $59 |
| Qwen 27B | 27/29 | $361 | $365 | $63 |
| Qwen 2B | 27/29 | $361 | $351 | $49 |
120 00 is nonnumeric under the frozen rulesThis material has not yet been fully vetted and checked.

demo_row = ocr_labels.loc[
ocr_labels.row_id.eq(f"{demo_page}_28")].iloc[0]
demo_image = PILImage.open(ocr_dir / "pages" / f"{demo_page}.png")
x, y, w, h = (int(demo_row[k]) for k in ["x", "y", "width", "height"])
display(demo_image.crop((x, y, x + w, y + h)).resize(
(1100, round(h * 1100 / w)), PILImage.Resampling.LANCZOS))
display(HTML(demo_row[["surname", "given_names", "birth_year",
"occupation"]].to_frame().T.to_html(index=False)))
| surname | given_names | birth_year | occupation |
|---|---|---|---|
| Vallet | Etienne | 1900 | o cheminot |
07 is a birth year as written, not an age. Expanding dates, occupation codes, and ditto marks is a separate research decision| Source split | Used in this lecture | Purpose |
|---|---|---|
| Training | No pages used for fitting | Available for a future fine-tune |
| Validation | 2 pages / 2 writers / 60 people | Choose prompt and image size |
| Test | 4 pages / 4 other writers / 120 rows | Compare saved predictions |
Start the local server with the Chandra OCR 2 weights downloaded earlier:
In a separate Python session, from the repository root:
import base64
from io import BytesIO
from pathlib import Path
from PIL import Image
image_path = (Path("slides/data/handwriting/pages")
/ "AD075DP_D2M8_275_0025_left_page.png")
image = Image.open(image_path)
image = image.resize((image.width * 2, image.height * 2),
Image.Resampling.LANCZOS)
buffer = BytesIO()
image.convert("RGB").save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode()from chandra.prompts import OCR_PROMPT
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8081/v1",
api_key="local", timeout=300)
response = client.chat.completions.create(
model="build/handwriting/model",
messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{encoded}"}},
{"type": "text", "text": OCR_PROMPT},
]}],
temperature=0, max_tokens=8192,
)
html = response.choices[0].message.content
print(html)encoded is the preceding slide’s image. OCR_PROMPT requests Chandra’s HTML transcription; no reference answers enter the requestOpenAI client talks to our local MLX server with a placeholder key. Code: single-page script, batch script| surname | given_names | birth_year | occupation |
|---|---|---|---|
| Chambon | Marguerite | 84 | o modiste |
| Dumont | Thérèse | 98 | É commerce |
| Mauchaud | Yvonne | 99 | giletière |
With Claude Code installed and signed in, from the repository root:
import json, shutil, subprocess, tempfile
from pathlib import Path
data = Path("slides/data/handwriting")
image_path = data / "pages/AD075DP_D2M8_275_0025_left_page.png"
instruction = (data / "claude_prompt.txt").read_text()
with tempfile.TemporaryDirectory() as folder:
shutil.copyfile(image_path, Path(folder) / "census.png")
result = subprocess.run(
["claude", "-p", "--model", "opus", "--output-format", "json",
"--tools", "Read", "--allowedTools", "Read"],
input=instruction + "census.png", cwd=folder,
text=True, capture_output=True, check=True,
)
print(json.loads(result.stdout)["result"])claude_prompt.txt requests ten transcription fields, without reference answers. Batch script; CLI docs. The saved run resolved opus to Opus 5.5NaN, preserving the other columns. An extracted blank remains a predictionocr_scores = pd.read_csv(ocr_dir / "scores/field_scores.csv")
claude_scores = pd.read_csv(ocr_dir / "claude_scores/field_scores.csv")
fields_shown = ["surname", "given_names", "birth_year", "occupation"]
accuracy_table = pd.DataFrame({
"Field": ["Surname", "Given names", "Birth year (digits)", "Occupation"],
})
for label, scores in [("Chandra OCR 2 (page)", ocr_scores),
("Claude Opus 5.5", claude_scores)]:
scores = scores.set_index("field")
accuracy_table[label] = [scores.loc[f, "digit_accuracy"
if f == "birth_year" else "character_accuracy"] for f in fields_shown]
display(HTML(accuracy_table.to_html(formatters={
"Chandra OCR 2 (page)": "{:.1%}".format,
"Claude Opus 5.5": "{:.1%}".format,
}, index=False)))
ocr_summary = pd.read_csv(ocr_dir / "scores/summary.csv").iloc[0]
claude_summary = pd.read_csv(ocr_dir / "claude_scores/summary.csv").iloc[0]
display(Markdown(
f"Separate run, Chandra row crops: **{ocr_summary.cer:.1%} CER**, "
f"**{ocr_summary.wer:.1%} WER**; "
f"{int(ocr_summary.exact_lines)}/120 exact lines."
))| Field | Chandra OCR 2 (page) | Claude Opus 5.5 |
|---|---|---|
| Surname | 86.9% | 95.4% |
| Given names | 91.1% | 98.4% |
| Birth year (digits) | 94.2% | 97.8% |
| Occupation | 56.8% | 90.0% |
Separate run, Chandra row crops: 35.1% CER, 78.1% WER; 0/120 exact lines.
structure = pd.read_csv(ocr_dir / "scores/structure.csv")
claude_structure = pd.read_csv(ocr_dir / "claude_scores/structure.csv")
structure_table = pd.DataFrame([
{"Model": name,
"Automatic cells": f"{s.automatic_cells:.0f}/1200",
"After source audit": f"{s.available_cells:.0f}/1200",
"Automatic exact recovery": f"{s.automatic_exact_yield:.1%}"}
for name, s in [("Chandra OCR 2", ocr_summary),
("Claude Opus 5.5", claude_summary)]
])
display(HTML(structure_table.to_html(index=False)))| Model | Automatic cells | After source audit | Automatic exact recovery |
|---|---|---|---|
| Chandra OCR 2 | 895/1200 | 1185/1200 | 47.1% |
| Claude Opus 5.5 | 900/1200 | 1190/1200 | 54.8% |
NaNNaN, retaining the other columns; Claude has no such spansocr_errors = ocr_cells.loc[
ocr_cells.available & ocr_cells.filled & ocr_cells.correct.eq(0)
& ocr_cells.field.isin(["surname", "given_names", "occupation"])]
error_id = ocr_errors.iloc[0].row_id
error_row = ocr_labels.set_index("row_id").loc[error_id]
error_image = PILImage.open(
ocr_dir / "pages" / f"{error_row.page_id}.png")
x, y, w, h = (int(error_row[k]) for k in ["x", "y", "width", "height"])
display(error_image.crop((x, y, x + w, y + h)).resize(
(1100, round(h * 1100 / w)), PILImage.Resampling.LANCZOS))
error_table = ocr_cells.loc[
ocr_cells.row_id.eq(error_id) & ocr_cells.field.isin(fields_shown),
["field", "reference", "prediction"],
]
display(HTML(error_table.to_html(index=False)))
| field | reference | prediction |
|---|---|---|
| surname | Brandarge | Brandorge |
| given_names | Robert | Robert |
| birth_year | 22 | 22 |
| occupation |
labeled available exact_agreement
Blank reference 394 380 0.928947
Filled reference 806 805 0.460870
Start the MLX server as in the setup commands, then:
mkdir -p build/handwriting/chandra
export OCR_MODEL=build/handwriting/model
uv run --extra handwriting \
python utilities/build_handwriting_predictions.py \
--split test --mode pages --output build/handwriting/chandra
uv run --extra handwriting \
python utilities/build_handwriting_predictions.py \
--split test --mode rows --output build/handwriting/chandra
uv run --extra handwriting \
python utilities/build_handwriting_scores.py \
build/handwriting/chandra --output build/handwriting/scoresbuild/The thesis reports this split for its final blur-trained model:
| Role | Pages | Purpose |
|---|---|---|
| Training | 110 | Learn the adapter; includes 33 pages from 1855 |
| Development (“evaluation”) | 14 | Select the model; includes 3 pages from 1855 |
| Final test | 14 | Excluded from training and tuning |
This material has not yet been fully vetted and checked.

Saucystream/fine_tuned_chandra adapts the original Chandra, an 8B model. The Paris example above uses Chandra OCR 2A holds surname and given names, C age, H occupation, I literacy, and K property ownership"*"; split columns become lists. These conventions are part of the task the adapter learnsThese calls fetch existing parameters from Hugging Face:
import torch
from peft import PeftModel
from transformers import AutoModelForImageTextToText
base = AutoModelForImageTextToText.from_pretrained(
"datalab-to/chandra",
revision="bd40c21576564d31ba99f204e28ccd5c4c1751fa",
dtype=torch.bfloat16, device_map="cpu",
)
adapted = PeftModel.from_pretrained(
base, "Saucystream/fine_tuned_chandra",
revision="d94b4ca422dd87e06b55abdd955bb199879a545a",
)
merged = adapted.merge_and_unload()merge_and_unload() adds the learned updates to the base weights. No optimizer or training step occursbuild_handwriting_argentina_model.py also saves the weights, processor, and MLX configuration to build/handwriting/argentina-model. PEFT merging docs{
"A": [
"Zarazaga",
"José"
],
"C": 45,
"H": "Comerciante",
"I": "si",
"K": "si"
}
Comerciante means merchant"*") and ditto marks uninterpretedpeople = pd.DataFrame(argentina["data"])
names = pd.DataFrame(people["A"].tolist(), columns=["surname", "given_names"])
people = pd.concat([names, people[["B", "C", "H", "I", "K"]]], axis=1)
people = people.rename(columns={
"B": "sex", "C": "age", "H": "occupation",
"I": "literacy", "K": "property_owner",
})| surname | given_names | age | occupation |
|---|---|---|---|
| Zarazaga | José | 45 | Comerciante |
| De la Torre | Juana | 38 | * |
| Zarazaga | José H. | 10 | * |
"*") and ditto marks remain uninterpreted; valid JSON does not verify a namecomparison = pd.read_csv(argentina_dir / "scores/summary.csv").set_index("model")
workflows = ["Chandra 1 / HTML", "Chandra 1 + LoRA / JSON",
"Chandra OCR 2 / HTML"]
result = comparison.loc[workflows]
rate_table = pd.DataFrame({
"Model / output": workflows,
"Character accuracy": result.character_accuracy.map("{:.1%}".format),
"Age digit accuracy": result.digit_accuracy.map("{:.1%}".format),
"Exact-cell errors": [f"{r.errors:.0f}/{r.available_cells:.0f}"
for r in result.itertuples()],
})
display(HTML(rate_table.to_html(index=False)))| Model / output | Character accuracy | Age digit accuracy | Exact-cell errors |
|---|---|---|---|
| Chandra 1 / HTML | 68.3% | 57.7% | 63/90 |
| Chandra 1 + LoRA / JSON | 96.6% | 100.0% | 3/90 |
| Chandra OCR 2 / HTML | 71.7% | 61.5% | 53/90 |
| HTML workflow | Periods only | no vs nó |
Ditto " vs '' |
|---|---|---|---|
| Original Chandra | 24 | 6 | 0 |
| Chandra OCR 2 | 22 | 6 | 3 |
4.5 where the reference gives 45. Punctuation can also change an economic variableJomalero, versus the adapter’s Jornalero (day laborer). Omitting it gives 2/89 = 2.2% exact disagreements| Model, with the author’s JSON prompt | Result | Cell errors |
|---|---|---|
| Original Chandra | 15 people; names not split | 51/90 = 56.7% |
| Chandra 1 + Argentina LoRA | 15 people; names split | 3/90 = 3.3% |
| Chandra OCR 2 | Repetition; 0/90 usable cells | Undefined |

build/handwriting_mlx.py loads the merged weights inside Python, without a server:
import json
from pathlib import Path
from mlx_vlm import load, generate
from mlx_vlm.prompt_utils import apply_chat_template
from mlx_vlm.utils import load_config
model_path = "build/handwriting/argentina-model"
image_path = "build/handwriting/argentina/holdout/source-000.png"
data = Path("slides/data/handwriting/argentina/holdout")
source = json.loads((data / "finetuned-json/source.json").read_text())
messages = [
{"role": "system", "content": source["system_prompt"]},
{"role": "user", "content": source["user_prompt"]},
]
model, processor = load(model_path)
prompt = apply_chat_template(
processor, load_config(model_path), messages, num_images=1,
)
result = generate(model, processor, prompt, image=[image_path],
max_tokens=8192, temperature=0.0, verbose=False)
print(result.text)source.json supplies the author’s full JSON instructions and made-up schema example; it supplies no answers for this pageOCR_PROMPT for the base-model HTML runsholdout = pd.read_csv(argentina_dir / "holdout/scores/summary.csv")
holdout_labels = ["Chandra 1 / HTML", "Chandra 1 + LoRA / JSON",
"Chandra OCR 2 / HTML"]
holdout = holdout.set_index("model").loc[holdout_labels]
holdout_table = pd.DataFrame({
"Model / output": ["Chandra 1 / HTML", "Original LoRA / JSON",
"Chandra OCR 2 / HTML"],
"Character accuracy": holdout.character_accuracy.map(
"{:.1%}".format).to_numpy(),
"Age digit accuracy": holdout.digit_accuracy.map(
"{:.1%}".format).to_numpy(),
"Exact-cell errors": [
f"{r.errors:.0f}/{r.available_cells:.0f} = {r.error_rate:.1%}"
for r in holdout.itertuples()],
})
display(HTML(holdout_table.to_html(index=False)))| Model / output | Character accuracy | Age digit accuracy | Exact-cell errors |
|---|---|---|---|
| Chandra 1 / HTML | 83.7% | 100.0% | 34/90 = 37.8% |
| Original LoRA / JSON | 92.9% | 96.2% | 11/90 = 12.2% |
| Chandra OCR 2 / HTML | 84.5% | 96.2% | 28/90 = 31.1% |
layout_table = pd.DataFrame({
"Model": holdout_table["Model / output"],
"Automatic cells": [f"{r.automatic_cells:.0f}/90"
for r in holdout.itertuples()],
"Assisted cells": holdout.assisted_cells.to_numpy(),
"Automatic exact recovery": holdout.automatic_exact_yield.map(
"{:.1%}".format).to_numpy(),
})
display(HTML(layout_table.to_html(index=False)))| Model | Automatic cells | Assisted cells | Automatic exact recovery |
|---|---|---|---|
| Chandra 1 / HTML | 90/90 | 0 | 62.2% |
| Original LoRA / JSON | 90/90 | 0 | 87.8% |
| Chandra OCR 2 / HTML | 36/90 | 54 | 31.1% |
from bs4 import BeautifulSoup
html_path = argentina_dir / "holdout/chandra2-html/prediction.html"
html_rows = BeautifulSoup(html_path.read_text(), "html.parser")
entries = [[td.get_text(strip=True) for td in tr.find_all("td")]
for tr in html_rows.find_all("tr") if tr.find_all("td")]
broken_rows = pd.DataFrame([entries[5][:6], entries[6][:6]], columns=[
"Row no.", "Name 1", "Name 2", "Sex", "Age", "Marital status",
])
display(HTML(broken_rows.to_html(index=False)))| Row no. | Name 1 | Name 2 | Sex | Age | Marital status |
|---|---|---|---|---|---|
| 6 | Colombo | Constantino | V. | 0 | |
| P.1 | 7 | Cabral | Miguel | V. | 57 |
P.1, a margin note, pushing the row number and name one column right| Model | Exact errors / 90 | Omit 5 uncertain cells / 85 |
|---|---|---|
| Chandra 1 / HTML | 34 | 30 |
| Original LoRA / JSON | 11 | 6 |
| Chandra OCR 2 / HTML | 28 | 23 |
V. versus V). Neither adapter has these discrepancies; digit and character scores retain punctuationThis material has not yet been fully vetted and checked.

ChandraOCR2_AppenDoc merges a fine-tune into Chandra OCR 2Download the Thai checkpoint, which already includes its adapter:
Reuse the Chandra Python call, changing the model and image paths:
image_path = "slides/data/handwriting/appen/input.png"
model, processor = load(model_path)
prompt = apply_chat_template(
processor, load_config(model_path), OCR_PROMPT, num_images=1,
)
result = generate(model, processor, prompt, image=[image_path],
max_tokens=2048, temperature=0.0, verbose=False)
print(result.text)handwriting_mlx.py accepts --model build/handwriting/appen-model; no separate adapter merge is needed
{
"quantity": "490",
"description": "ค่าถ่ายเอกสาร A4",
"unit_price": "0.50",
"amount": "245"
}
pd.read_html. The Thai description denotes A4 photocopying; \(490 \times 0.50 = 245\)