Contents
A passport check has two steps: first, that every character is "legal"; second, that the string looks like a passport number and not a barcode. Keep only the first step and the date 01.02.24 slips through as a measurement: after normalization it is nothing but digits and commas. The model will not argue — it learns whatever you feed it. A mistake in the alphabet or the indices often raises no exception at all: the network just quietly reads the wrong thing.
In lesson 1 we set up the lab. In lesson 2 we fixed the input at [1, 48, 160]. Today the series Train CNN and CRNN from scratch agrees on labels: which string belongs to which cell crop.
Four words you will meet below:
- Label — the text that should come out of a crop:
7,3,12/14, or an empty string for an empty cell. - Alphabet — the list of characters allowed for one task on the form sheet (the paper form).
- Class — one possible answer the model can pick at a decision point: "this is
7," "this is a comma," "nothing here." Every class has its own index (a number). When people say "12 classes," they mean the model can choose one of twelve options — not "twelve different neural networks." This is not a class in the OOP sense and not a school class — it is the classification term: how many entries are on the answer menu. A detector for "empty / filled cell" has two classes; a line reader has as many classes as there are characters in the alphabet, plus one special entry: the blank. - Blank (
blank) — a separate class meaning "no character here." Picture the network scanning a crop column by column and choosing at each one: a digit, a comma… or "nothing." That "nothing" is the blank. Do not confuse it with a blank form sheet: here the word has nothing to do with paper, and in this lesson "form sheet" always means the paper form. The blank is also not the digit0in the label105and not a space: our labels contain no spaces, and zero is an ordinary alphabet character with index 1 (the first one in the string0123456789…). The blank takes index 0, so the digit "zero" and "no character" can never be mixed up. Why the blank exists at all is covered in lesson 10 (CTC). For today one thing matters: the numbering already reserves a slot for it.
The form sheet has three types of cells — three tasks: measurement (measurement), diameter (diameter), thickness (thickness). Each has its own alphabet and its own format rule. That is why a single "all characters are on the list" check is not enough: you need a second line of defense — the format.

Files in this lesson
src/alphabet.py— task alphabets, the index contract, normalization, encoding, format validation.src/demo_labels.py— what happens to raw strings at the pipeline input.tests/test_alphabet.py— twelve tests; they double as the specification.
You put the files into your own repository from lesson 1 and commit once the tests are green. Below, in "Write it yourself," all three files sit in collapsed blocks — expand and copy them. The reference tag lesson-03 and the starter lesson-03-start hold the same texts on GitHub if you prefer reading them there.
Predict
"Predict" is not an exam. You fix an expectation before running anything, then compare it with demo_labels.py and pytest. It is especially useful to guess why the date is rejected — and why not by the alphabet.
Before you run, write the answers:
- How many classes (including the blank) will the
measurement,diameter, andthicknesstasks have? - What index will the character
,get in themeasurementalphabet? - The string
01.02.24consists of allowed characters after normalization. Should the system accept it as a measurement?
Specification
The alphabet is the model's contract: which characters can exist at all. The number of classes follows directly — how many different answers the network can choose. The function num_classes(task) computes exactly that: the alphabet length plus one special class — the blank. The same number is the width of the model's last layer (one "knob" per class). Indices in training and in decoding must match to the unit: otherwise the loss and the decoder look at different characters under the same number.

Alphabets and indices.
| Task | Alphabet | Characters | Classes (with blank) |
|---|---|---|---|
measurement |
0123456789, |
11 | 12 |
diameter |
0123456789/ |
11 | 12 |
thickness |
0123456789,- |
12 | 13 |
The index contract: blank = 0 (blank in code), characters are numbered from 1 in the order they appear in the alphabet string. A class index is the number that goes into the training label and that decode turns back into a character.
An example for measurement and the string 7,3:
- the character
7is eighth in0123456789,→ index 8; - the comma is the eleventh character → index 11;
- the character
3is the fourth → index 4.
Result: 7,3 → [8, 11, 4].
Functions. In the lesson-03-start starter the "done" column is already written; your work is the rows marked you (in the starter, three TODO blocks). In your own repository you type the finished functions too — otherwise there is nothing for encode to call.
| Function | What it does | Who writes it |
|---|---|---|
get_alphabet(task), num_classes(task) |
the alphabet string and the class count; an unknown task raises ValueError |
done |
normalize_label(text) |
strip whitespace, . → ,, different dashes → a plain - |
done |
encode_text(text, task) |
string → list of indices; a character outside the alphabet raises ValueError; an empty string → [] |
you |
decode_indices(indices, task) |
indices → string; 0, a negative index, or one beyond the alphabet raises ValueError |
you |
FORMAT_PATTERNS |
regular expressions of the formats per task | you |
validate_format(text, task) |
(ok, reason): alphabet first, then format |
you |
The order at the pipeline input (the generator in lesson 4 will do the same):
normalize_label— bring the raw string to canonical form;validate_format— filter out garbage;encode_text— get the indices for the model.
Formats. An empty string is valid for every task — it is an empty cell. The rule looks at the already normalized string.

| Task | The rule in plain words | Accepted | Rejected |
|---|---|---|---|
measurement |
an integer of 1–3 digits; optionally a comma and 1–2 more digits | 7, 7,3, 105, 0,85, 123,45 |
1234, 7,, ,5, 7,345, 01,02,24 |
diameter |
an integer of 1–3 digits; optionally / and 1–3 more digits |
57, 12/14, 120/80 |
12/, /14, 12/14/16 |
thickness |
one "number" or two joined by a hyphen; each number is 1–2 digits, optionally with a comma and 1–2 digits | 10, 1-2, 1,5-2, 1,5-2,5 |
-2, 1-, 1--2, 1-2-3 |
The rejection reason from validate_format must contain the word "алфавит" (a character outside the list) or "формат" (the characters are fine, the shape is wrong). That is how the tests tell the two kinds of rejection apart. These are the Russian words "alphabet" and "format," and they stay Russian on purpose: the companion repository and the tests assert exactly those strings, so the code blocks in this article are identical to the Russian edition.
Two decisions worth making before any code:
encode_textdoes not normalize the string. Otherwise7.3would quietly turn into a valid7,3and labeling mistakes would hide. Normalization happens once, at the pipeline input; encode only checks that what arrives is already clean.decode_indicesdoes not accept the blank. The blank is not a text character. Collapsing repeats and removing blanks is the job of lesson 10; only indices of real characters arrive here.
Write it yourself
First put the files into the repository from lesson 1. The blocks below are collapsed — expand and copy. The alphabet.py starter matches the lesson-03-start tag: the finished functions are already there, the three TODO blocks are empty. You can also type everything from scratch following the specification — the tests are the same.
Run the commands after you fill in the TODOs (the next section and the "Commands" block).
src/alphabet.py — starter (expand)
"""Урок 3 — «напиши сам»: алфавиты, контракт индексов и проверка формата меток.
Что нужно сделать (три пункта TODO ниже), пока не пройдут все тесты:
python -m pytest tests/test_alphabet.py -q
Контракт индексов (общий для всего курса):
blank = 0, символы алфавита нумеруются с 1 в порядке следования в строке.
"""
import re
BLANK_INDEX = 0
ALPHABETS = {
"measurement": "0123456789,",
"diameter": "0123456789/",
"thickness": "0123456789,-",
}
_DASHES = "\u2010\u2011\u2012\u2013\u2014\u2212" # разные «тире» и минус
# --- Готово: можно не трогать ------------------------------------------------
def get_alphabet(task: str) -> str:
try:
return ALPHABETS[task]
except KeyError:
raise ValueError(f"неизвестная задача {task!r}; доступны: {sorted(ALPHABETS)}") from None
def num_classes(task: str) -> int:
"""Размер выходного слоя модели: символы алфавита + blank."""
return len(get_alphabet(task)) + 1
def normalize_label(text: str) -> str:
"""Приводит сырую метку к каноническому виду: без пробелов, ',' вместо '.', обычный '-'."""
text = "".join(text.split())
text = text.replace(".", ",")
for dash in _DASHES:
text = text.replace(dash, "-")
return text
# --- TODO 1: форматы -----------------------------------------------------------
# Для каждой задачи нужен re.compile(...), который проверяется через fullmatch.
# Используйте [0-9], а не \d: \d совпадает и с цифрами других письменностей.
#
# measurement: 1–3 цифры, затем (необязательно) запятая и 1–2 цифры.
# Подходят: 7 7,3 105 0,85 123,45
# diameter: 1–3 цифры, затем (необязательно) косая черта и 1–3 цифры.
# Подходят: 57 12/14 120/80
# thickness: «число» = 1–2 цифры, затем (необязательно) запятая и 1–2 цифры.
# Метка = число, затем (необязательно) дефис и ещё одно число.
# Подходят: 10 1-2 1,5-2 1,5-2,5
#
# Пустая строка (пустая клетка) допустима для всех задач; её обрабатывает
# validate_format, регулярное выражение для неё писать не нужно.
FORMAT_PATTERNS: dict[str, "re.Pattern[str]"] = {}
# --- TODO 2: кодирование -------------------------------------------------------
def encode_text(text: str, task: str) -> list[int]:
"""Строка → список индексов (blank = 0, символы с 1).
Строку заранее нормализует вызывающий код: символ вне алфавита — ValueError.
Пустая строка даёт пустой список.
"""
raise NotImplementedError
def decode_indices(indices, task: str) -> str:
"""Список индексов → строка.
Индекс 0 (blank), отрицательный индекс и индекс за пределами алфавита — ValueError.
"""
raise NotImplementedError
# --- TODO 3: проверка формата --------------------------------------------------
def validate_format(text: str, task: str) -> tuple[bool, str]:
"""Проверяет метку и возвращает (ok, причина).
Порядок проверок:
1. любой символ вне алфавита -> (False, причина со словом «алфавит»);
2. пустая строка -> (True, "");
3. не подходит под формат -> (False, причина со словом «формат»);
4. иначе -> (True, "").
"""
raise NotImplementedError
src/demo_labels.py — expand
"""Демо: алфавиты, нормализация, кодирование и проверка формата."""
from alphabet import (
ALPHABETS, BLANK_INDEX, decode_indices, encode_text, get_alphabet,
normalize_label, num_classes, validate_format,
)
CASES = [
("7.3", "measurement"),
("12/14", "diameter"),
("12/14", "measurement"),
("01.02.24", "measurement"),
("7.345", "measurement"),
("1\u20132", "thickness"),
("", "measurement"),
]
def main() -> None:
print(f"blank = {BLANK_INDEX}, символы нумеруются с 1\n")
for task in ALPHABETS:
print(f"{task:<12} алфавит {get_alphabet(task)!r:<16} символов: {len(get_alphabet(task)):>2}"
f" классов с blank: {num_classes(task)}")
print()
for raw, task in CASES:
norm = normalize_label(raw)
try:
ok, reason = validate_format(norm, task)
except NotImplementedError:
print(
"TODO в src/alphabet.py ещё не дописаны "
"(validate_format / encode_text / FORMAT_PATTERNS).\n"
"Сначала доведите тесты до зелёных:\n"
" python -m pytest tests/test_alphabet.py -q\n"
"Демо — после 12 passed (или смотрите эталон lesson-03)."
)
raise SystemExit(1) from None
head = f"{raw!r:<11} {task:<12} → {norm!r:<10}"
if ok:
try:
idx = encode_text(norm, task)
back = decode_indices(idx, task)
except NotImplementedError:
print(
"TODO в src/alphabet.py ещё не дописаны (encode_text / decode_indices).\n"
"Сначала: python -m pytest tests/test_alphabet.py -q"
)
raise SystemExit(1) from None
assert back == norm
print(f"{head} → {idx} → {back!r} OK")
else:
print(f"{head} → REJECT: {reason}")
if __name__ == "__main__":
main()
tests/test_alphabet.py — expand
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from alphabet import ( # noqa: E402
BLANK_INDEX, decode_indices, encode_text, get_alphabet,
normalize_label, num_classes, validate_format,
)
ACCEPT = {
"measurement": ["", "7", "7,3", "105", "0,85", "123,45", "007"],
"diameter": ["", "57", "12/14", "120/80"],
"thickness": ["", "10", "1-2", "1,5-2", "1,5-2,5", "12-15"],
}
REJECT_FORMAT = {
"measurement": ["1234", "7,", ",5", "7,345", "7,3,1", "01,02,24", "1,2,3"],
"diameter": ["12/", "/14", "12/14/16", "1234", "12/1234"],
"thickness": ["-2", "1-", "1--2", "1-2-3", "123", "1,", "1,5,5"],
}
REJECT_ALPHABET = {
"measurement": ["12/14", "7.3", "abc", "7 3"],
"diameter": ["7,3", "7.3"],
"thickness": ["7/3", "1_2"],
}
def test_blank_is_zero():
assert BLANK_INDEX == 0
assert encode_text("0", "measurement") == [1]
def test_num_classes():
assert num_classes("measurement") == 12
assert num_classes("diameter") == 12
assert num_classes("thickness") == 13
def test_encode_examples():
assert encode_text("7,3", "measurement") == [8, 11, 4]
assert encode_text("12/14", "diameter") == [2, 3, 11, 2, 5]
assert encode_text("1,5-2", "thickness") == [2, 11, 6, 12, 3]
assert encode_text("", "measurement") == []
def test_encode_rejects_foreign_char():
with pytest.raises(ValueError):
encode_text("7.3", "measurement") # точку нормализует вызывающий код
with pytest.raises(ValueError):
encode_text("12/14", "measurement")
def test_decode_roundtrip():
for task, texts in ACCEPT.items():
for text in texts:
assert decode_indices(encode_text(text, task), task) == text
def test_decode_rejects_blank_and_out_of_range():
with pytest.raises(ValueError):
decode_indices([0], "measurement") # blank — не символ
with pytest.raises(ValueError):
decode_indices([12], "measurement") # в алфавите всего 11 символов
with pytest.raises(ValueError):
decode_indices([-1], "measurement")
def test_normalize_label():
assert normalize_label("7.3") == "7,3"
assert normalize_label(" 7, 3 ") == "7,3"
assert normalize_label("1\u20132") == "1-2"
assert normalize_label("01.02.24") == "01,02,24"
assert normalize_label("") == ""
def test_validate_accepts():
for task, texts in ACCEPT.items():
for text in texts:
ok, reason = validate_format(text, task)
assert ok, f"{task}: {text!r} должна приниматься, а отклонена: {reason}"
def test_validate_rejects_by_format():
for task, texts in REJECT_FORMAT.items():
for text in texts:
ok, reason = validate_format(text, task)
assert not ok, f"{task}: {text!r} должна отклоняться"
assert "формат" in reason, f"{task}: {text!r}: причина должна содержать «формат»"
def test_validate_rejects_by_alphabet():
for task, texts in REJECT_ALPHABET.items():
for text in texts:
ok, reason = validate_format(text, task)
assert not ok, f"{task}: {text!r} должна отклоняться"
assert "алфавит" in reason, f"{task}: {text!r}: причина должна содержать «алфавит»"
def test_trap_passes_alphabet_but_not_format():
# Дата после normalize_label целиком состоит из допустимых символов.
text = normalize_label("01.02.24")
assert all(ch in get_alphabet("measurement") for ch in text)
ok, reason = validate_format(text, "measurement")
assert not ok and "формат" in reason
def test_unknown_task():
with pytest.raises(ValueError):
get_alphabet("nope")
with pytest.raises(ValueError):
encode_text("1", "nope")
Hints:
![RegExp traps: \d vs [0-9], match vs fullmatch](/assets/img/blog/cnn-crnn-from-scratch-03-alphabet/fig-regexp-traps.webp)
str.findreturns-1for a character it cannot find. Without a check,pos + 1gives 0 — the blank index. The model would get "no character" instead of an error. This is the nastiest trap of the lesson (see the diagram below).re.matchchecks only the start of the string. For the format you needfullmatch: otherwise1234passes as123with a tail.- In expressions write
[0-9], not\d: in Python\dalso matches digits from other scripts (for example the Arabic-Indic٣).

Exercise
The main task is the three TODO blocks in alphabet.py: FORMAT_PATTERNS, the encode_text / decode_indices pair, and validate_format. On the starter, pytest will first report 10 failed, 2 passed — that is expected while the TODOs are empty. Fill in the functions and re-run the tests until you get 12 passed.
Bonus: invent a trap string — one that passes the alphabet but not the format (for example, an extra separator). Add a test to tests/test_alphabet.py (a name like test_trap_passes_alphabet_but_not_format).
When the tests are green, move on to "Commands" and compare the demo with "Expected." If you are stuck, the full solution is below (better to open it after an attempt).
Solution src/alphabet.py (open after an attempt)
The full file from the lesson-03 tag. The comments in encode_text point at that very find → blank trap.
"""Алфавиты задач, контракт индексов и проверка формата меток.
Контракт индексов (общий для всего курса):
blank = 0, символы алфавита нумеруются с 1 в порядке следования в строке.
"""
import re
BLANK_INDEX = 0
ALPHABETS = {
"measurement": "0123456789,",
"diameter": "0123456789/",
"thickness": "0123456789,-",
}
# Формат метки поверх алфавита (пустая строка = пустая клетка, допустима всегда).
# Пишем [0-9], а не \d: \d в Python совпадает и с цифрами других письменностей.
FORMAT_PATTERNS = {
"measurement": re.compile(r"[0-9]{1,3}(,[0-9]{1,2})?"),
"diameter": re.compile(r"[0-9]{1,3}(/[0-9]{1,3})?"),
"thickness": re.compile(r"[0-9]{1,2}(,[0-9]{1,2})?(-[0-9]{1,2}(,[0-9]{1,2})?)?"),
}
_DASHES = "\u2010\u2011\u2012\u2013\u2014\u2212" # разные «тире» и минус
def get_alphabet(task: str) -> str:
try:
return ALPHABETS[task]
except KeyError:
raise ValueError(f"неизвестная задача {task!r}; доступны: {sorted(ALPHABETS)}") from None
def num_classes(task: str) -> int:
"""Размер выходного слоя модели: символы алфавита + blank."""
return len(get_alphabet(task)) + 1
def normalize_label(text: str) -> str:
"""Приводит сырую метку к каноническому виду: без пробелов, ',' вместо '.', обычный '-'."""
text = "".join(text.split())
text = text.replace(".", ",")
for dash in _DASHES:
text = text.replace(dash, "-")
return text
def encode_text(text: str, task: str) -> list[int]:
"""Строка → список индексов (blank = 0, символы с 1).
Строку заранее нормализует вызывающий код: символ вне алфавита — ошибка.
"""
alphabet = get_alphabet(task)
indices = []
for ch in text:
pos = alphabet.find(ch) # -1, если символа нет
if pos < 0: # иначе pos+1 даст 0 = blank — коварная ошибка
raise ValueError(f"символ {ch!r} вне алфавита задачи {task!r}")
indices.append(pos + 1) # символы с 1, blank = 0
return indices
def decode_indices(indices, task: str) -> str:
"""Список индексов → строка. Индекс 0 (blank) и выход за алфавит — ошибка."""
alphabet = get_alphabet(task)
chars = []
for i in indices:
if not 1 <= i <= len(alphabet): # blank и «мусор» сюда не входят
raise ValueError(f"индекс {i} недопустим для задачи {task!r} (допустимо 1..{len(alphabet)})")
chars.append(alphabet[i - 1]) # индекс 1 → первый символ алфавита
return "".join(chars)
def validate_format(text: str, task: str) -> tuple[bool, str]:
"""Проверяет метку: сначала алфавит, потом формат. Возвращает (ok, причина)."""
alphabet = get_alphabet(task)
for ch in text:
if ch not in alphabet: # сначала алфавит, потом формат
return False, f"символ {ch!r} вне алфавита"
if text == "":
return True, ""
if FORMAT_PATTERNS[task].fullmatch(text) is None: # match() оставил бы хвост
return False, "формат не соответствует задаче"
return True, ""
What to notice: the pos < 0 check is mandatory; the format goes through fullmatch; in the regexes use [0-9], not \d.
Commands
When the TODOs are filled in (or you checked against the solution), from the repository root:
python -m pytest tests/test_alphabet.py -q # цель: 12 passed
python src/demo_labels.py
If you run the demo before the TODOs are filled in, the script reminds you to finish alphabet.py — that is not a broken repository.
Expected
The output of demo_labels.py on the solution (tag lesson-03). Read the arrows left to right: raw string → normalization → indices or REJECT. In the terminal the word blank is the blank class itself (that is how the demo code prints it). The words after REJECT: are Russian on purpose — see the note on reason strings above.
blank = 0, символы нумеруются с 1
measurement алфавит '0123456789,' символов: 11 классов с blank: 12
diameter алфавит '0123456789/' символов: 11 классов с blank: 12
thickness алфавит '0123456789,-' символов: 12 классов с blank: 13
'7.3' measurement → '7,3' → [8, 11, 4] → '7,3' OK
'12/14' diameter → '12/14' → [2, 3, 11, 2, 5] → '12/14' OK
'12/14' measurement → '12/14' → REJECT: символ '/' вне алфавита
'01.02.24' measurement → '01,02,24' → REJECT: формат не соответствует задаче
'7.345' measurement → '7,345' → REJECT: формат не соответствует задаче
'1–2' thickness → '1-2' → [2, 12, 3] → '1-2' OK
'' measurement → '' → [] → '' OK
Tests: 12 passed.
Checking against "Predict":
measurementanddiameterhave 12 classes each,thicknesshas 13;- the comma in
measurementis index 11; - the date
01.02.24is rejected by format, not by alphabet: the characters are allowed, the shape is wrong.
Remember the pair "12 classes for measurement and diameter" — we return to it later in the course (lesson 16). Commit the lesson once the output matches.
If it fails
- A TODO message from
demo_labels.py(or earlier, aNotImplementedErrortraceback) — you ran the demo before finishingalphabet.py. Get pytest to12 passedfirst. encode_text("7.3", ...)returned[8, 0, 4]— there is no check afterfind; addif pos < 0. The dot, which is outside the measurement alphabet, turned into the blank.1234passes the format — you usere.matchinstead offullmatch.007or123,45are rejected — the regex is too strict; compare with the format table (leading zeros and123,45must pass).- The rejection-reason test fails — the reason text must contain the words "алфавит" or "формат".
ModuleNotFoundError: alphabet— run pytest and the demo from the root of your repository.
Checklist
-
src/alphabet.py,demo_labels.py, and the tests are written in your repository and committed -
demo_labels.pymatches the "Expected" block -
pytest→ 12 passed - The three forecasts from "Predict" are checked
FAQ
What is a "class" in this lesson?
One entry on the model's answer menu. For measurement the menu is: blank, 0, 1, …, 9, , — 12 classes in all. The label 7,3 is not one class but a sequence of classes [8, 11, 4]. The empty-cell detector (lessons 7–9) picks one class for the whole crop; the line reader (later) picks a class at every step along the image.
Why is the blank a separate class with index 0?
A line-reading model needs the answer "no character in this column" — separate from the digit 0 in the text. If you give characters indices from zero or mix the blank with "zero," training and decoding drift apart. So index 0 is reserved for the blank in advance (blank in code), and the digit 0 lives at index 1. How the blank takes part in CTC is in lesson 10.
Why three different alphabets if all of them have digits?
The form sheet has different cells: a measurement with a decimal comma, a diameter with a slash, a thickness with a range hyphen. One "universal" alphabet would bloat the model's output and mix the formats — the network would start confusing tasks.
Why is normalize_label already written in the reference while encode is not?
Normalization is pipeline policy ("how we store a label"). Encoding and format are the core of the contract with the model; that is what we practice writing from the tests' specification. In your own repository you still type normalize_label — it just is not an exercise with traps.
Do we accept 007 as a measurement?
Yes, if the string is already canonical: the format allows leading zeros in the integer part. This is a deliberate course decision; in production you can add a separate number normalization.
Can I call encode_text on a raw string with a dot?
Better not: you will get a ValueError or, without the find check, silent corruption through the blank. First normalize_label, then validate_format, then encode — the generator in lesson 4 will work the same way.
Why do measurement and diameter both have 12 classes?
They have the same number of characters in the alphabet (11 + the blank). This will matter later, when we split the model heads (lesson 16).
What comes next
The label contract is ready. The next step is Lesson 4. Synthetic generator and all.jsonl. The Dataset and augmentations are lessons 5–6.
Reference for comparison: lesson-03. You do not need to clone it. Previous: Lesson 2. Image → tensor.



Comments