Results
Three genuine VAMC valuation-request appendix pages, 86 scoreable fields, exact
match after Unicode NFC normalisation. Same prompt, same fields, same pages for
every row. Greedy decoding — the base row was measured twice in separate runs and
reproduced to the decimal.
Table with columns: Field exact match, Gold string present | Field exact match | Gold string present |
|---|
| Base model, zero-shot | 64.0% | 88.4% |
| + adapter, 1 epoch | 75.6% | 86.0% |
| + adapter, 2 epochs | 81.4% | 90.7% |
| + adapter, 3 epochs | 81.4% | 90.7% |
+17.4 pp. Two and three epochs are identical field-for-field on every page,
so training converged at two; the 2-epoch adapter is the one published here.
Per page, so variance is visible rather than averaged away:
Table with columns: Page, Fields, Base, 2 epochs, Δ| Page | Fields | Base | 2 epochs | Δ |
|---|
| 1406, appendix p.2 | 30 | 50.0% | 83.3% | +33.3 pp |
| 1348, appendix p.2 | 29 | 69.0% | 79.3% | +10.3 pp |
| 1309, appendix p.2 | 27 | 74.1% | 81.5% | |
A principal + interest = total consistency check passes on every page, before and
after tuning.
What the training actually fixed
The base model already reads these pages — 88.4% of gold values appear somewhere
in its output. What it lacked was field discipline: it returns
8994/QĐ-CT ngày 22/11/2019 where the schema asks for 8994/QĐ-CT, and it fills
fields the page does not contain instead of returning null. The gap between
"present somewhere" and "exactly right" fell from 24.4 pp to 9.3 pp, and the
substring figure rose as well — precision was not bought by dropping recall.
Multilingual behaviour
Adapting to one language usually costs the others. Measured on 60 synthetic pages
held out of training, 20 per language:
Table with columns: Vietnamese, Korean, English | Vietnamese | Korean | English |
|---|
| Base model | 77.8% | 63.3% | 75.0% |
| 2 epochs | 93.3% | 92.2% | 91.1% |
Korean gains 28.9 pp, English 16.1 pp. The training mix was deliberately 60%
Vietnamese / 20% Korean / 20% English for exactly this reason.
How a regression was found and fixed
The first training run improved the average but made one page worse — page
1348 fell from 69.0% to 65.5%. Its address fields are printed in abbreviated form
(Q. Tân Phú, TP.HCM) while the first synthetic corpus always spelled the
administrative prefix out (huyện …, tỉnh …). The adapter had learned to
normalise toward the only form it had seen.
Adding abbreviated forms to the generator — along with varied currency units
(VNĐ / đồng / VND / đ), numbered appendix titles, three term-of-use
phrasings, unnumbered mortgage contracts whose gold value is null, and
certificate numbers containing spaces — lifted that page to 79.3%, above its
untuned score, and every other page improved too.
All of those variations were taken from features observed in real documents, not
guessed. This is the whole method: measure, find where it fails, identify why,
change the data, measure again.
Intended use
Extracting structured records so a person can verify and confirm them before they
reach a database. Output must be reviewed. At 81.4% exact match roughly one
field in five still needs correction, and the model does not know which one.
Pair it with validation rules. The principal + interest = total check above
catches row-level confusion that an accuracy figure hides.
Training
- Base:
Qwen/Qwen2.5-VL-7B-Instruct, 4-bit NF4, bfloat16 compute, flash-attn 2
- LoRA r=16, alpha=32, dropout 0.05, on
q,k,v,o,gate,up,down
- 47,589,376 trainable parameters — 0.57% of 8.34B
- AdamW, lr 1e-4 cosine, effective batch 4 (grad accumulation 4)
- 1,740 synthetic pages, 2 epochs, ~55 min on one H200 (≈42 GB available)
- Data: ysmeta/EVE-Train-NPL-1.1,
which ships the generator source so the corpus is reproducible
extraction_prompt.txt here is the exact prompt behind every number above.
The prompt matters: one line telling the model which table row the debt totals
come from fixed a row-confusion error before any training happened. All reported
gains are measured against the base model using this same prompt.
Why the evaluation set is not published
Evaluation uses letters VAMC posts publicly on sbvamc.vn. Those pages carry
personal data — staff names and phone numbers, borrower details, collateral
property addresses. Collecting them into a downloadable dataset would turn
scattered public postings into a searchable corpus, which is a different act from
the original publication. The evaluation set is kept private. Document numbers
and the source site are cited so results stay checkable. The synthetic training
data is published in full.
Limitations
- Three evaluation pages. One field is 1.16% of the score. Every figure is
provisional; the per-page table shows how wide the spread is.
- One document family — VAMC valuation-request appendices. Other Vietnamese
financial paperwork is untested.
- Flat 30-field schema. Real filings sometimes list many collateral assets
across several pages; a flat record cannot represent that. Four fields were
excluded from scoring on two pages where the document was genuinely ambiguous —
for instance an address printed in both pre- and post-reform administrative
forms.
- Korean and English were evaluated on synthetic pages only. No genuine
Korean or English NPL document was tested.
- No source coordinates. The adapter does not output bounding boxes, so a
value cannot be highlighted on the original page. Qwen2.5-VL supports grounding
natively; this adapter was simply not trained for it.
null discipline improved but is not solved.
Usage
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
from peft import PeftModel
from qwen_vl_utils import process_vision_info
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
"Qwen/Qwen2.5-VL-7B-Instruct", device_map="auto", dtype="bfloat16")
model = PeftModel.from_pretrained(model, "ysmeta/EVE-OCR-1.0-NPL")
proc = AutoProcessor.from_pretrained("Qwen/Qwen2.5-VL-7B-Instruct")
prompt = open("extraction_prompt.txt").read()
msgs = [{"role": "user", "content": [
{"type": "image", "image": "page.png"}, {"type": "text", "text": prompt}]}]
text = proc.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
imgs, vids = process_vision_info(msgs)
inputs = proc(text=[text], images=imgs, videos=vids, return_tensors="pt").to("cuda")
out = model.generate(**inputs, max_new_tokens=900, do_sample=False)
print(proc.batch_decode(out[:, inputs.input_ids.shape[-1]:], skip_special_tokens=True)[0])
Render scanned PDFs to roughly 1,100–1,250 px on the short edge, matching the
evaluation pages. Pages that already carry a text layer should be parsed
directly — running a model over them replaces a correct answer with a guess.