OCR models that turn table images into text have an odd habit. Give one a table where the same row repeats, and it writes one row too few. Think of a transaction list with the same date and amount on eight lines. Change a single value per row and it reads every row fine.
I looked into why PaddleOCR-VL-1.5 (a 0.9B document-parsing model) does this. The method is attention masking. When the model writes a token, I block part of what it can look at, then see how the output changes. The weights stay the same; the masks apply only at inference.
Every table in these experiments is synthetic, and the values are made up.
The symptom: identical-row tables come out a row short
The model writes tables as OTSL-style tokens:
<fcel>: a cell with text<ecel>: an empty cell<lcel>,<ucel>: a cell merged with the one to its left or above<nl>: the end of a row
Writing EOS (the end-of-output token) after the last <nl> ends the table.
I made two kinds of 8-row table, 40 of each. In one kind every row is identical. In the other every row is different.
| Table | Wrote all 8 rows | Rows missing | Rows written |
|---|---|---|---|
| Every row different | 40 (100%) | 0 | 8 rows in all 40 |
| Every row identical | 1 (2.5%) | 39 (97.5%) | 7 rows in 35, 6 in 4, 8 in 1 |
On identical-row tables the model usually stops after 7 rows. The text it writes is right; it’s the row count that’s short.
Its gaze runs ahead of what it writes
I measured which image row the model looks at while it writes each row.
- How: the correct output goes in as history (teacher forcing), and attention is averaged over all 18 layers.
- Size: 60 table pairs.
Switch the table type and the moment in the figure below.
Each row's image attention is scaled to 100%. The last image row always gets a little attention, even on tables with unique rows.
On unique-row tables the pattern is a diagonal. While writing row 3, the model looks at image row 3 most.
On identical-row tables the gaze gets ahead from row 4 on.
- While writing row 4, it looks at image row 5 most.
- While writing row 6, it’s already on row 8, the last one.
- The row-end
<nl>shows the same thing. At the<nl>that ends row 5, the most-watched row is already row 8. It should be row 6.
The reason is plausible. In an identical-row table, rows 4 and 5 look exactly the same in the image. So it’s easy to lose track of where you are. The gaze reaches the last row early, so the model decides it’s done and stops.
The probability of stopping, P(EOS), fits this picture:
| Table | After row 6 | After row 7 | After row 8 (the real end) |
|---|---|---|---|
| Every row different | 0.0% | 0.0% | 99.9% |
| Every row identical | 19.8% | 88.6% | 77.9% |
On identical-row tables the model stops after row 7 with 88.6% probability.
Attention only points at a cause, though. It doesn’t prove one. So the next step was to apply masks.
Fourteen masks
There are two kinds of mask:
- Masks on every output token.
- Masks only on the row-end
<nl>token. These touch only the moment the model decides whether to stop.
I added two controls that hide the same number of positions at random. They separate the effect of what’s hidden from the effect of how much is hidden.
This time the model wrote the whole output on its own. I used 40 pairs of 8-row tables.
- Exact: the output matches the answer token for token.
- Text similarity: a character-level match that includes the cell separators.
| Mask | Identical rows: row count right | Short | Over | Exact | Text similarity | Unique rows: exact | Text similarity |
|---|---|---|---|---|---|---|---|
| No mask | 2.5 | 97.5 | 0 | 2.5 | 86.3 | 97.5 | 99.8 |
| Only the current row (needs row positions) | 100 | 0 | 0 | 80 | 99.1 | 85 | 99.3 |
<nl> can’t see earlier <nl> | 45 | 52.5 | 2.5 | 45 | 92.8 | 97.5 | 99.8 |
| Hide rows already read | 0 | 100 | 0 | 0 | 74.1 | 90 | 99.8 |
At <nl>, hide rows already read | 0 | 100 | 0 | 0 | 82.2 | 97.5 | 99.8 |
At <nl>, see only the next row | 0 | 100 | 0 | 0 | 49.8 | 97.5 | 99.8 |
| Hide rows not yet read | 0 | 75 | 25 | 0 | 35.4 | 0 | 33.8 |
At <nl>, hide rows not yet read | 0 | 100 | 0 | 0 | 48.7 | 2.5 | 69.7 |
At <nl>, hide the image | 5 | 95 | 0 | 0 | 34.2 | 0 | 61.5 |
<nl> can’t see earlier text | 0 | 97.5 | 2.5 | 0 | 56.7 | 0 | 66.7 |
<nl> can’t see any earlier output | 0 | 0 | 100 | 0 | 75.4 | 0 | 84.0 |
Control: <nl> loses random earlier output | 0 | 0 | 100 | 0 | 75.5 | 0 | 84.0 |
| Hide earlier rows’ output | 0 | 0 | 100 | 0 | 76.4 | 0 | 49.2 |
Control: at <nl>, hide random image tokens | 0 | 100 | 0 | 0 | 78.2 | 27.5 | 91.0 |
| Hide the whole image | 0 | 95 | 5 | 0 | 23.0 | 0 | 22.9 |
All values are percentages of 40 tables, so a gap of a few points can be chance.
The figure below shows P(EOS) right after each row, per mask. The gray line is the identical-row table with nothing hidden. Switch masks and compare the points after rows 7 and 8.
Average over 60 identical-row table pairs, measured with the correct output as history. A good line stays low through row 7 and jumps at row 8.
Pinning the gaze to the current row fixes every table
“Only the current row” gets the row count right on all 40 identical-row tables. Exact matches go from 2.5% to 80%. Here’s what it does:
- While the model writes a row’s cells, it can see only that row of the image.
- At
<nl>, it can see only the next row.
P(EOS) is 0.1% after row 7 and 99.6% after row 8. When the gaze can’t run ahead, the symptom goes away. That fits the “it loses its place” explanation.
But this mask is a diagnostic, not a fix.
- It needs the answer. You have to tell it where each row is in the image.
- It hurts other tables. On unique-row tables, exact matches drop from 97.5% to 85%.
- It’s worse on realistic tables. On a separate, more realistic set, unique-row tables dropped from 72% to 28%.
Hiding earlier <nl> tokens from <nl> brings back about half
Of the masks that don’t need row positions, this was the only one that helped. It does one thing: the row-end <nl> token can’t see the <nl> tokens written before it.
- Exact matches on identical-row tables go from 2.5% to 45%.
- Unique-row tables are unchanged: row count 100%, exact 97.5%.
- P(EOS) after row 7 falls from 88.6% to 46.4%.
I haven’t confirmed why it works. My guess: earlier <nl> tokens may carry a running sense of which row the model is on. On identical-row tables that sense has already drifted a row or two ahead. A new <nl> that reads it inherits the error. Hiding the earlier <nl> tokens cuts that chain. Testing this needs its own experiment.
I also tried one synthetic table of each of a few other shapes. It’s one table per shape, so don’t read these as rates.
| Table shape | Rows | No mask | <nl> can’t see earlier <nl> | Only the current row |
|---|---|---|---|---|
| Header + 8 identical rows | 9 | 8 rows (1 short) | exact | exact |
| Merged first column + identical rows | 9 | 8 rows (1 short) | exact | 9 rows, one cell wrong |
| Only the middle 4 rows identical | 8 | 7 rows (1 short) | 8 rows, one merge token differs | exact |
| Repayment schedule | 9 | exact | exact | exact |
| Every row different | 10 | exact | exact | exact |
Three masks that only looked like fixes
When <nl> can’t see any earlier output, the “short” rate on identical-row tables drops to 0%. The same is true for the random control and for “hide earlier rows’ output”. If you count only short outputs, it looks solved.
But all three run over on all 40 tables. Most never stop and get cut off at the length limit. With every earlier output hidden from <nl>, P(EOS) never goes above 0.1% after any row. If the model can’t see what it already wrote, it can’t tell that it’s done.
So in experiments like this, always count overshoot along with undershoot.
Fixing only the stop decision makes it worse
When <nl> can see only the next image row, identical-row tables stop even earlier.
- The model writes 3 to 5 rows, and text similarity falls from 86.3 to 49.8.
- P(EOS) is already 35% after row 3.
The next row looks exactly like the one just written, so the model seems to treat it as already done.
The other masks tell us a few more things:
- Hiding rows already read changes nothing. So confusing a row with the previous one isn’t the cause.
- Hiding rows not yet read makes it stop earlier. The model keeps writing only while it can see rows left.
- Hiding the whole image makes it invent tables. It fills cells with the numbers 1 to 99, or repeats one word over and over.
What this experiment can’t tell you
- One model, synthetic tables only. I didn’t measure dropped rows on real documents. Real tables that lose rows without any repetition aren’t explained by this.
- The samples are small. Rates come from 40 pairs, attention from 60 pairs, and the shape examples are one table each.
- The attention runs and the full runs use different conditions. Attention and P(EOS) were measured with the correct output as history. They may differ when the model writes on its own.
- It isn’t ready for serving. To use “
<nl>can’t see earlier<nl>”, the inference server (vLLM, for example) has to support a different attention mask per token.
Takeaways
- When rows repeat, the model loses its place. Its gaze runs a row or two ahead, reaches the last row early, and it stops.
- Pinning the gaze to the current row removes the symptom. It needs row positions, though, so it’s a diagnostic.
- Hiding earlier
<nl>tokens from<nl>brought back about half the missing rows without row positions, and didn’t hurt other tables. It’s worth testing on real tables. - If you count only short outputs, a mask that never stops looks like a fix. Count overshoot too.
표 이미지를 글자로 옮기는 OCR 모델에는 이상한 버릇이 하나 있어요. 같은 행이 여러 번 반복되는 표를 주면 행을 하나 덜 써요. 같은 날짜, 같은 금액이 여덟 줄 찍힌 거래 내역 같은 표예요. 값이 하나라도 다르면 멀쩡하게 다 읽어요.
PaddleOCR-VL-1.5(0.9B 크기의 문서 인식 모델)로 이 버릇이 왜 생기는지 살펴봤어요. 쓴 방법은 attention 마스크예요. 모델이 토큰 하나를 쓸 때 볼 수 있는 곳 일부를 일부러 막아요. 그다음 결과가 어떻게 바뀌는지 봐요. 가중치는 건드리지 않고, 추론할 때만 막아요.
실험에 쓴 표는 모두 합성이고, 값도 지어낸 거예요.
증상: 같은 행 표는 거의 다 한 행이 모자라요
모델은 표를 OTSL 형식 토큰으로 써요. 쓰이는 토큰은 이래요.
<fcel>: 글자가 든 칸<ecel>: 빈 칸<lcel>,<ucel>: 왼쪽 칸이나 위 칸과 합친 칸<nl>: 행 끝
마지막 <nl> 다음에 EOS(출력을 끝내는 토큰)를 쓰면 표가 끝나요.
8행짜리 표를 두 종류로 40개씩 만들었어요. 하나는 모든 행이 똑같은 표이고, 하나는 모든 행이 다른 표예요.
| 표 | 8행을 맞게 씀 | 행이 모자람 | 쓴 행 수 |
|---|---|---|---|
| 행이 모두 다름 | 40개 (100%) | 0개 | 8행 40개 |
| 행이 모두 같음 | 1개 (2.5%) | 39개 (97.5%) | 7행 35개, 6행 4개, 8행 1개 |
같은 행 표에서 모델은 대부분 7행을 쓰고 멈춰요. 글자는 틀리지 않아요. 행 수만 모자라요.
시선이 쓰는 행보다 앞서 가요
모델이 각 행을 쓰는 동안 이미지의 어느 행을 보는지 쟀어요.
- 잰 방법: 정답 출력을 이력으로 넣고(teacher forcing), 18개 층의 attention 을 평균 냈어요.
- 규모: 표 60쌍이에요.
아래 그림에서 표 종류와 잰 순간을 바꿔 보세요.
값은 이미지 attention 을 행마다 100% 로 맞춘 비율이에요. 마지막 이미지 행에는 행이 다른 표에서도 늘 조금씩 시선이 가요.
행이 모두 다른 표는 대각선이에요. 3번째 행을 쓸 때는 이미지의 3번째 행을 가장 많이 봐요.
행이 모두 같은 표는 4번째 행부터 시선이 앞서 가요.
- 4번째 행을 쓸 때 5번째 이미지 행을 가장 많이 봐요.
- 6번째 행을 쓸 때는 벌써 마지막인 8번째 행을 봐요.
- 행 끝
<nl>에서도 같아요. 5번째 행을 끝낸<nl>에서 가장 많이 보는 곳이 이미 8번째 행이에요. 원래는 6번째 행을 봐야 해요.
이유는 그럴듯해요. 같은 행 표에서는 4번째 행과 5번째 행이 이미지에서 똑같이 생겼어요. 그래서 “지금 어디를 읽고 있나”를 놓치기 쉬워요. 시선이 마지막 행에 먼저 닿으니, 모델은 다 읽었다고 보고 일찍 끝내요.
끝내는 확률(P(EOS))도 이 그림과 맞아요.
| 표 | 6번째 행 뒤 | 7번째 행 뒤 | 8번째 행 뒤 (진짜 끝) |
|---|---|---|---|
| 행이 모두 다름 | 0.0% | 0.0% | 99.9% |
| 행이 모두 같음 | 19.8% | 88.6% | 77.9% |
같은 행 표는 7번째 행만 쓰고도 88.6% 확률로 끝내요.
다만 attention 은 원인을 가리키는 근거일 뿐, 증명은 아니에요. 그래서 다음 단계로 마스크를 걸어 봤어요.
마스크 14개를 걸어 봤어요
마스크는 두 종류예요.
- 모든 출력 토큰에 거는 마스크
- 행 끝
<nl>토큰에만 거는 마스크: 끝낼지 말지 정하는 순간만 건드려요
여기에 대조군을 두 개 더했어요. 같은 개수의 위치를 무작위로 가리는 마스크예요. 가린 양만으로 생기는 변화를 걸러 내려고 넣었어요.
이번에는 모델이 처음부터 끝까지 스스로 쓰게 했어요. 8행 표 40쌍으로 쟀어요.
- 토큰 정답: 출력이 정답과 토큰까지 같아요.
- 글자 유사도: 칸 구분까지 넣어 글자를 맞춰 본 유사도예요.
| 마스크 | 같은 행: 행 수 맞음 | 모자람 | 넘침 | 토큰 정답 | 글자 유사도 | 다른 행: 토큰 정답 | 글자 유사도 |
|---|---|---|---|---|---|---|---|
| 안 가림 | 2.5 | 97.5 | 0 | 2.5 | 86.3 | 97.5 | 99.8 |
| 지금 행만 보게 함 (행 위치 필요) | 100 | 0 | 0 | 80 | 99.1 | 85 | 99.3 |
<nl> 이 앞 <nl> 을 못 봄 | 45 | 52.5 | 2.5 | 45 | 92.8 | 97.5 | 99.8 |
| 읽은 행을 못 봄 | 0 | 100 | 0 | 0 | 74.1 | 90 | 99.8 |
<nl> 에서 읽은 행을 못 봄 | 0 | 100 | 0 | 0 | 82.2 | 97.5 | 99.8 |
<nl> 에서 다음 행만 봄 | 0 | 100 | 0 | 0 | 49.8 | 97.5 | 99.8 |
| 안 읽은 행을 못 봄 | 0 | 75 | 25 | 0 | 35.4 | 0 | 33.8 |
<nl> 에서 안 읽은 행을 못 봄 | 0 | 100 | 0 | 0 | 48.7 | 2.5 | 69.7 |
<nl> 에서 이미지를 못 봄 | 5 | 95 | 0 | 0 | 34.2 | 0 | 61.5 |
<nl> 이 앞 글자를 못 봄 | 0 | 97.5 | 2.5 | 0 | 56.7 | 0 | 66.7 |
<nl> 이 앞 출력을 모두 못 봄 | 0 | 0 | 100 | 0 | 75.4 | 0 | 84.0 |
대조: <nl> 의 앞 출력을 무작위로 가림 | 0 | 0 | 100 | 0 | 75.5 | 0 | 84.0 |
| 앞 행 출력을 못 봄 | 0 | 0 | 100 | 0 | 76.4 | 0 | 49.2 |
대조: <nl> 에서 이미지를 무작위로 가림 | 0 | 100 | 0 | 0 | 78.2 | 27.5 | 91.0 |
| 이미지 전체를 못 봄 | 0 | 95 | 5 | 0 | 23.0 | 0 | 22.9 |
단위는 모두 % 이고, 표 40개 기준이에요. 그래서 몇 %p 차이는 우연일 수 있어요.
아래 그림은 마스크마다 행을 쓴 직후의 P(EOS) 예요. 회색 선은 같은 행 표에 아무것도 가리지 않았을 때예요. 마스크를 바꿔 가며 7번째와 8번째 행 뒤를 비교해 보세요.
같은 행 표 60쌍의 평균이에요. 정답 출력을 이력으로 넣고 쟀어요. 좋은 선은 7번째 행까지 낮고, 8번째 행에서 높아요.
시선을 지금 행에 묶으면 전부 되찾아요
“지금 행만 보게 함”은 같은 행 표 40개의 행 수를 모두 맞혀요. 토큰까지 정답인 것도 2.5% 에서 80% 로 올라요. 동작은 이래요.
- 칸을 쓰는 동안에는 이미지에서 그 행만 보게 해요.
<nl>에서는 다음 행만 보게 해요.
P(EOS) 를 보면 7번째 행 뒤에는 0.1% 이고, 8번째 행 뒤에는 99.6% 예요. 시선이 앞서 가지 못하게 막으면 증상이 사라진다는 뜻이에요. “어디를 읽는지 놓쳐서”라는 설명과 맞아요.
하지만 이 마스크는 고치는 방법이 아니라 진단 도구예요.
- 정답이 필요해요. 이미지에서 각 행이 어디 있는지를 알려 줘야 걸 수 있어요.
- 다른 표가 나빠져요. 행이 모두 다른 표의 토큰 정답 비율이 97.5% 에서 85% 로 떨어져요.
- 현실에 가까운 표에서는 더 나빠요. 별도로 만든 표 묶음에서는 행이 모두 다른 표가 72% 에서 28% 까지 떨어졌어요.
<nl> 이 앞 <nl> 을 못 보게 하면 절반쯤 되찾아요
행 위치 없이 걸 수 있는 마스크 중에서는 이것만 효과가 있었어요. 하는 일은 하나예요. 행 끝 <nl> 토큰이 앞서 쓴 <nl> 토큰들을 보지 못하게 해요.
- 같은 행 표의 토큰 정답이 2.5% 에서 45% 로 올라요.
- 행이 다른 표는 안 가렸을 때와 같아요. 행 수는 100%, 토큰 정답은 97.5% 예요.
- 7번째 행 뒤 P(EOS) 는 88.6% 에서 46.4% 로 내려가요.
왜 효과가 있는지는 확인하지 못했어요. 제 추측은 이래요. 앞의 <nl> 토큰들에는 “지금 몇 번째 행인가”라는 정보가 담겨 있을 수 있어요. 같은 행 표에서는 그 정보가 이미 한두 행 앞서 있어요. 새 <nl> 이 그걸 보고 따라가면 오차도 같이 넘어가요. 앞 <nl> 을 못 보게 하면 이 전달이 끊겨요. 이 설명이 맞는지는 따로 실험해야 해요.
모양이 다른 합성 표에도 하나씩 걸어 봤어요. 표마다 하나씩이라 비율로 읽으면 안 돼요.
| 표 모양 | 행 수 | 안 가림 | <nl> 이 앞 <nl> 을 못 봄 | 지금 행만 보게 함 |
|---|---|---|---|---|
| 머리글 + 같은 행 8개 | 9 | 8행 (1행 모자람) | 정답 | 정답 |
| 첫 열 병합 + 같은 행 | 9 | 8행 (1행 모자람) | 정답 | 9행, 한 칸 틀림 |
| 가운데 4행만 같음 | 8 | 7행 (1행 모자람) | 8행, 병합 토큰 하나 다름 | 정답 |
| 상환 일정표 | 9 | 정답 | 정답 | 정답 |
| 행이 모두 다름 | 10 | 정답 | 정답 | 정답 |
좋아 보이기만 한 마스크 세 개
<nl> 이 앞 출력을 모두 못 보게 하면, 같은 행 표에서 “행이 모자란” 경우가 0% 가 돼요. 무작위 대조군과 “앞 행 출력을 못 봄”도 마찬가지예요. 모자람만 보면 문제를 다 고친 것 같아요.
하지만 셋 다 40개 모두 행이 넘쳐요. 대부분 끝내지 못하고 길이 상한에서 잘려요. <nl> 이 앞 출력을 모두 못 볼 때는 P(EOS) 가 어느 행 뒤에서도 0.1% 를 넘지 않아요. 앞에 쓴 내용을 못 보면 “다 썼다”는 판단도 못 하는 거예요.
그래서 이런 실험에서는 모자람과 넘침을 꼭 같이 세야 해요.
끝내는 순간만 고치면 더 나빠져요
<nl> 에서 이미지의 다음 행만 보게 하면, 같은 행 표가 더 일찍 끝나요.
- 쓰는 행 수가 3~5행으로 줄고, 글자 유사도는 86.3 에서 49.8 로 떨어져요.
- P(EOS) 는 3번째 행 뒤에 벌써 35% 예요.
다음 행이 방금 쓴 행과 똑같이 생겼으니, 모델은 “이건 이미 썼다”고 보는 것 같아요.
나머지 마스크에서 알 수 있는 것도 있어요.
- 읽은 행을 가려도 효과가 없어요. 앞 행과 헷갈리는 게 원인은 아니라는 뜻이에요.
- 안 읽은 행을 가리면 더 일찍 끝나요. 모델은 남은 행이 보여야 계속 써요.
- 이미지를 통째로 가리면 표를 지어내요. 숫자 1부터 99까지 칸을 채우거나, 같은 단어를 끝없이 반복해요.
이 실험으로 말할 수 없는 것
- 모델 하나, 합성 표만 썼어요. 실제 문서에서 행이 모자라는 경우는 재지 않았어요. 같은 행이 없는 실제 표에서 행이 모자라는 문제는 이 실험으로 설명하지 못해요.
- 표본이 작아요. 비율은 40쌍, attention 은 60쌍, 모양별 예시는 하나씩이에요.
- attention 과 끝까지 쓴 결과의 조건이 달라요. attention 과 P(EOS) 는 정답 이력을 넣고 쟀어요. 그래서 모델이 스스로 쓸 때와 다를 수 있어요.
- 서빙에 바로 넣을 수 없어요. “
<nl>이 앞<nl>을 못 봄”을 쓰려면 추론 서버(예: vLLM)에 토큰마다 다른 attention 마스크를 넣어야 해요.
정리
- 같은 행이 반복되면 모델은 어디를 읽는지 놓쳐요. 시선이 한두 행 앞서 가고, 마지막 행에 먼저 닿아서 일찍 끝내요.
- 시선을 지금 행에 묶으면 증상이 사라져요. 하지만 행 위치를 알려 줘야 하니 진단용이에요.
<nl>이 앞<nl>을 못 보게 하는 마스크는 행 위치 없이 절반쯤 되찾고, 다른 표는 해치지 않았어요. 실제 표에서 다시 확인해 볼 만해요.- 행이 모자란 경우만 세면 “끝내지 못하는” 마스크가 정답처럼 보여요. 넘침도 같이 세세요.