OCR: знайти текст і прочитати

Блок 6 закінчився питанням «якому предмету належить цей піксель». Тут відповідь уперше перестає бути картинкою: на виході — рядок символів. І це єдина тема курсу, де поруч із нашою моделлю стоїть готовий промисловий рушій, якому можна поставити ті самі питання.

Оголошення на дошці: фото телефона, а на фото — цінник. Щоб дошка могла шукати за ціною, комусь треба перетворити ділянку пікселів на рядок «4500 грн». Задача звучить дрібно поруч із сегментацією, з якої ми щойно вийшли: там модель відповідала за кожен піксель окремо, тут вона має видати десяток символів. Але саме ця дрібна задача ламає всі звички, набуті в перших шести блоках, — і ламає їх у першому ж реченні постановки.

ПостановкаОдне слово, дві задачі

Слово OCR (optical character recognition, розпізнавання тексту) означає не одну задачу, а дві, поставлені одна за одною:

КрокПитанняВихідНа що схоже з курсу
детекція текстуде на знімку рядки?список рамокдетекція з блоку 5
розпізнавання рядкащо написано в цій рамці?рядок символівнічого з попередніх блоків

Перший крок ми фактично вже вміємо. Рамка навколо рядка тексту — це та сама рамка, що навколо машини в темі 23, тільки предмет інший: довгий, тонкий і без сталого співвідношення сторін. Метрики ті самі — IoU, mAP, NMS.

Другий крок — новий цілком. Досі кожна наша модель віддавала або одне число (клас), або масив сталого розміру (карта ознак, маска). Тут вихід — послідовність невідомої довжини: «4500 грн» це вісім символів, «Продам телефон терміново» — двадцять чотири, і мережа мусить сама вирішити, скільки їх.

Чому ці два кроки не зливають в один? Спокуса є: одна мережа, яка дивиться на фото й видає весь текст. Такі моделі існують, але майже всі промислові системи досі розділені, і на це є проста причина, яку ми зараз заміряємо: розпізнавач уміє читати рівно один рядок, і вся його точність тримається на тому, що детектор дав йому саме один рядок і саме цілком.

Наскрізний приклад теми. Ми всюди читатимемо ціни з оголошень — той самий домен, що в курсі ML. Власний код малює цифри растровим шрифтом 5×7, оголошеним прямо в коді теми: так усі числа відтворяться в тебе незалежно від того, які шрифти стоять у системі. Системний шрифт зʼявиться рівно один раз — коли ми порівнюватимемо себе з готовим рушієм.

СегментаціяЧому не можна різати рядок на літери

Найочевидніший план такий: розріж рядок на символи, а далі класифікуй кожен символ окремо. Класифікація дрібних картинок — задача з блоку 2, вона розвʼязана.

План помирає на першому кроці, і помирає красиво. Щоб розрізати рядок на символи, треба вже знати, де вони — а це і є розпізнавання, тільки складніше: тепер модель має не просто назвати літери, а ще й знайти межі між ними.

Здається, що межі видно й так: між літерами є проміжок. Перевіримо це правило — «порожній стовпець пікселів розділяє символи» — на трьохстах рядках із трьох-пʼяти цифр.

проміжок між цифрамирядків поділено правильношматків на символ
завжди 1 px1.00001.0000
1 або 2 px1.00001.0000
0, 1 або 2 px0.39330.7935
0 або 1 px0.25000.6990

Доки між цифрами лишається хоч один порожній стовпець, правило працює ідеально — і саме тому воно так довго здавалося робочим. Досить проміжкам іноді зникати, і правильно поділених рядків лишається 39.33 %.

Заміряймо це точніше. Візьмімо всі сто пар цифр і поставмо їх упритул, без жодного проміжку: 81 пара зі 100 злипається в один звʼязний шматок. Вижили рівно девʼятнадцять — усі ті, де є одиниця. У нашому шрифті одиниця вузька, її крайні стовпці порожні, тож вона сама створює проміжок там, де його не заклали.

Порожній стовпець — не межа символу

Згори — рядок цифр растровим шрифтом 5×7, бірюзовим обведено шматки, які знайшло наївне правило. Під ним смуга «чорнило в стовпці»: рожеве — стовпець не порожній, отже різати тут не можна.

символів—
шматків—
прочитано—
CER—
Постав проміжок у нуль — і сусідні цифри стануть одним шматком, який шаблон упізнає як щось третє. Тепер додавай зайві пікселі по одному: кожен із них або зшиває два шматки в один, або сам стає новим «символом», і вже третій-четвертий валить читання. Обидві поломки не в класифікаторі, а в тому кроці, який мав би його врятувати.

Друга поломка того самого правила зовсім інша й тому цінна. Досить кільком випадковим пікселям упасти на папір між цифрами — і порожніх стовпців не лишається взагалі, рядок стає одним шматком. Тобто наївна сегментація ламається двома різними способами: від злиття символів і від зникнення проміжків.

Це не проблема шрифту. У нашому растровому шрифті всі цифри однакової ширини й проміжки задаються явно. У справжньому тексті ширина літер різна («і» проти «ш»), кернінг зводить пари літер упритул навмисно, а рукописний текст злитий увесь. Ми взяли найлегший можливий випадок — і навіть він розсипався від одного зниклого пікселя.

Висновок, який визначає всю решту теми: сегментацію на символи треба не покращувати, а прибирати. Модель має читати рядок цілком і сама вирішувати, де закінчується один символ і починається інший — не окремим кроком, а всередині відповіді.

РамкаРамка вирішує, що прочитається

Перш ніж будувати модель, варто побачити, що з нею робить перший крок. Зберемо найпростіший шаблонний читач — рівно те, що робив класичний рушій до появи мереж:

  1. узяти рамку;
  2. знайти в ній смужку з чорнилом і звести її до сталої висоти сім пікселів;
  3. порізати по порожніх стовпцях;
  4. кожен шматок звести до 5×7 і порівняти з десятьма шаблонами.

Крок другий тут головний, і він є в кожному рушії, зокрема й у нейромережевому: модель має сталий вхід, тож смужку хоч-не-хоч зводять до однієї висоти. Саме через нього рамка перестає бути «дрібницею розмітки».

рамка навколо рядка «4500»прочитаноCER
точно по рядку45000.0000
із запасом 2 px45000.0000
із запасом 4 px45000.0000
зачепила другий рядок71429911.5000
обидва рядки разом704000.7500
зрізано верх 2 px40000.2500
зрізано низ 2 px40000.2500
зрізано правий край45080.2500

Три речі варто прочитати з цієї таблиці окремо.

запас нешкідливий

Зайві порожні пікселі навколо рядка не коштують нічого: читач сам знаходить смужку з чорнилом. Тому вимога «рамка точно по буквах» — міф, і детектор, який трохи завеликий, нікому не заважає.

зрізаний край коштує рівно стільки, скільки зрізано

Половина рамки — половина рядка. Це лінійна, передбачувана, майже нецікава поломка: скільки символів випало з рамки, стільки й не прочиталось.

два рядки в одній рамці — повна поразка

А ось це поломка іншого роду. Смужку зводять до сталої висоти, і два рядки, стиснуті в сім пікселів, не схожі ні на що. Помилка не пропорційна: рамка більша на кілька пікселів — і читається сміття. CER тут 1.5000, тобто більший за одиницю: модель не просто помилилась, вона дописала зайвого.

Рамка й те, що з неї виходить

Ліворуч — знімок із двома рядками цін і рамка, яку ти рухаєш. Праворуч — смужка, зведена до сталої висоти сім пікселів, і те, що з неї прочиталося.

висота рамки—
CER—
прочитано—
Опусти низ рамки до 16 — і вона зачепить верхівку другого рядка. Прочитане не «трохи зіпсується», а перестане мати спільне з істиною: смужку доведеться стиснути вдвічі, і жоден шаблон уже не підійде. А тепер підніми верх до нуля — нічого не станеться, бо порожній папір читача не бентежить.

Те саме, тільки різкіше, дає промисловий рушій. Якщо покласти в одну рамку два рядки й сказати йому --psm 7, тобто «тут рівно один рядок», він поверне одну літеру «я» і CER рівно 1.0000. Той самий знімок із --psm 6, тобто «тут блок тексту», читається без жодної помилки. Різниця не в моделі й не в картинці — різниця в тому, що йому сказали про рамку.

І ще різкіше — якщо розрізати рядок навпіл горизонтально: верхня половина дає CER 1.0870, нижня 0.9130. Порівняй це з вертикальним розрізом, який відрізає рівно стільки символів, скільки випало з рамки. Розпізнавач рядка читає вздовж рядка; поперечний розріз він переживає, поздовжній — ні.

Отже відповідь на питання «навіщо два кроки, а не один»: тому що ці два кроки ламаються по-різному й вимірюються по-різному. Детекцію міряють через IoU — і рамка з IoU 0.85 вважається доброю. Але IoU 0.85 у високої тонкої рамки означає кілька зайвих пікселів по висоті, а це рівно та поломка, що дає CER 1.5000. Метрика першого кроку не бачить того, що вбиває другий.

МетрикаЧим міряти: відстань Левенштейна

Досі всі наші метрики порівнювали речі однакового розміру: маску з маскою, рамку з рамкою. Тут треба порівняти два рядки різної довжини, і звична «частка збігів по позиціях» одразу бреше: досить моделі пропустити першу літеру, і всі решта позиції зсунуться, хоч текст майже правильний.

Правильна відповідь — відстань Левенштейна (Levenshtein distance): найменша кількість дій, потрібних, щоб перетворити один рядок на інший. Дій рівно три, і всі вони коштують одиницю:

вставити символ · викинути символ · замінити символ

Порахуймо руками. Істина «телефон», модель прочитала «тепефон». Перші дві літери збігаються, третя різна: «л» проти «п». Одна заміна — і рядки однакові. Відстань 1.

Складніше: істина «телефон», модель прочитала «телефзон». Тут нічого замінювати не треба — досить викинути зайву «з». Знову відстань 1, хоча по позиціях збігається лише пʼять символів із семи: усе після «ф» зсунулось на одну клітинку. Саме це й робить відстань Левенштейна правильною метрикою: вона розуміє, що вставка зсуває все решта, і не карає за зсув двічі.

Рахують її таблицею. У клітинці (i, j) стоїть відстань між першими i символами істини й першими j символами прогнозу. Кожна клітинка — мінімум із трьох сусідів плюс одиниця, і лише по діагоналі одиниця не додається, якщо символи збіглися. Заповнивши таблицю, у правому нижньому куті дістаємо відповідь.

d(i, j) = min( d(i−1, j) + 1, ← викинути символ істини d(i, j−1) + 1, ← вставити символ прогнозу d(i−1, j−1) + (a ≠ b) ) ← лишити або замінити

З відстані роблять дві різні метрики, і плутати їх дорого.

CER = відстань Левенштейна / довжина істини

CER (character error rate, посимвольна похибка) каже, яка частка символів зіпсована. CER 0.0435 на рядку з двадцяти трьох символів означає рівно одну помилку. Метрика не обмежена зверху одиницею: якщо модель дописала зайвого, відстань може перевищити довжину істини, і CER вийде більшим за 1.

рядок цілком = частка рядків, прочитаних без жодної помилки

Друга метрика жорсткіша й ближча до життя: у пошуку за ціною рядок «4500» з однією помилкою так само марний, як рядок із пʼятьма.

Ці дві метрики розходяться з довжиною рядка, і це не тонкість, а головна пастка звітів про OCR.

помилка на символдовжина рядкаCERрядок цілком
0.0240.02030.9210
0.02120.01980.7880
0.02250.01880.6230
0.0540.04650.8280
0.05120.04560.5650
0.05250.04740.2880

Одна й та сама модель. CER не зрушив із місця — він і не мусить, бо міряє символи. А «рядок цілком» упав із 0.9210 до 0.6230 просто тому, що рядки стали довшими. Формула тут проста: якщо помилки на символах незалежні, то ймовірність чистого рядка дорівнює (1 − p)L, і вона спадає з довжиною експоненційно.

Відстань Левенштейна й дві метрики

Ліворуч — таблиця відстані між істиною «телефон» і прогнозом, із підсвіченим найдешевшим шляхом. Праворуч — як «рядок цілком» падає з довжиною при тій самій посимвольній похибці.

що прочитала модель
відстань—
CER—
які дії потрібні—
рядок цілком при цій довжині—
Варіант «латиниця» виглядає бездоганним — і це найгірший випадок у житті: там латинські e й o замість кирилиць. Око не бачить різниці, пошук не знаходить рядок, а CER чесно показує 0.4286. Посунь повзунок довжини — і побач, як та сама похибка перетворюється на майже нульову частку чистих рядків.

РушійПромисловий рушій під псуванням

Досі курс усе будував сам: свій CLIP у темі 22, свій YOLO в темі 25, свій SAM у темі 32. Тут уперше поруч стоїть готова навчена модель, яку можна запустити офлайн, — рушій tesseract, і він уміє українську.

Це дає рідкісну нагоду: замість здогадів «що заважає розпізнаванню» можна просто поламати картинку шістьма способами й подивитись на числа. Рядок один і той самий — «Продам телефон 4500 грн» та ще два подібні, щоб один щасливий випадок не збрехав.

Числа цього розділу залежать від машини. Вони заміряні з tesseract 5.3.2 і шрифтом Liberation Sans. На іншій версії рушія чи з іншим шрифтом третя цифра буде іншою. Важлива не вона, а порядок причин — він на диво стійкий і на диво несподіваний.
псуванняCERчи заважає
мова eng замість ukr0.7045вбиває
висота літери 8 px0.6187вбиває
нахил 15°0.4211сильно
нахил 7°0.1855помітно
нахил 5°0.0101майже ні
розмиття σ 2 при висоті 320.0000ні
контраст 10 % від початкового0.0000ні
контраст 2 % (розмах 6 рівнів сірого)0.0000ні

Останній рядок варто прочитати двічі. Картинка, на якій усе зображення вміщається в шість рівнів сірого — око бачить майже чистий білий аркуш, — читається без жодної помилки. Бінаризація всередині рушія адаптивна: вона дивиться на локальний перепад, а не на абсолютну яскравість, і їй байдуже, наскільки блідий текст, доки він відрізняється від паперу.

Де ця межа насправді, і чого ми не міряли. «Байдуже, наскільки блідий» звучить як твердження без краю, а край у нього є, і він арифметичний. Знімок у градаціях сірого зберігає яскравість цілим числом від 0 до 255. Контраст 2 % означає, що весь розмах від чорнила до паперу вміщається в 0.02 × 255 ≈ 5 сходинок, тобто в шість різних значень — мало, але перепад усередині кожної літери ще ціле число, і адаптивний поріг має що з чим порівняти. А от нижче 1/255 ≈ 0.4 % розмах округлюється до нуля: чорнило й папір дістають те саме число, і на знімку немає літер узагалі — справа тут уже не в рушієві, а в тому, що на вході немає тексту.

Між цими двома межами ми не міряли: 2 % — найнижчий рівень, який стоїть у таблиці, а не найнижчий, який працює. І ще одне обмеження, важливіше за перше: контраст тут псували наодинці, на чистому рядку висотою 32 px. Контраст стискає різницю «чорнило проти паперу», і будь-яке інше псування — шум, розмиття, дрібний шрифт — відкушує від того самого запасу. Тому «2 % не заважають» справджується для контрасту як єдиної вади й не переноситься на бліду й шумну картинку.

Звідси перший практичний висновок, протилежний тому, що написано в половині посібників: «почистити картинку» перед OCR — здебільшого марна робота. Перевіримо це прямо: візьмімо розмитий рядок і прогонимо через порогування Оцу, класичний спосіб «покращити» вхід.

розмитий рядок (σ 3)CER
як є0.0670
після порогування Оцу0.1673

Стало вдвічі гірше. Оцу вибирає один поріг на все зображення й викидає півтони — саме ту інформацію, якою внутрішня адаптивна бінаризація рушія скористалася б. Чужий крок обробки не додав нічого, зате зʼїв дані.

Нахил — найдешевша поломка

А ось нахил заважає, і заважає різко. Між 5° і 7° стоїть обрив: 0.0101 → 0.1855, тобто у вісімнадцять разів. Причина видна, щойно згадати попередній розділ: рушій ріже сторінку на рядки горизонтальними смугами. Нахилений рядок у горизонтальну смугу не влазить — його верх на одному кінці й низ на іншому опиняються в різних смугах, і ми знову маємо ту саму поломку, що в інтерактиві 2.

Найважливіше тут — що це лікується повністю й задарма. Повернімо рядок назад на той самий кут:

нахилCER як єCER після вирівнювання
7°0.18550.0000
10°0.19190.0000
15°0.42110.0000

Рівно нуль. Не «майже нуль», не «краще» — точнісінько той самий результат, що на непошкодженому рядку. Вирівнювання нахилу (deskew) — єдина передобробка з усіх, які ми перевірили, що справді щось дає, і вона коштує один поворот.

Справжня межа — висота літери

Найцікавіше — остання поломка. Заміряймо CER як функцію висоти шрифту в пікселях.

висота321614121110987
CER0.00000.00000.01450.01080.05620.17360.20530.61870.7910

До одинадцяти пікселів усе гаразд, з десяти починається сповзання, з восьми — вже сміття. Ось звідки береться правило «скануй у 300 DPI», яке всі повторюють, не пояснюючи: при 300 точках на дюйм звичайний кегль 10 pt дає літеру приблизно двадцять пікселів заввишки — рівно з дворазовим запасом над межею. При 150 DPI було б десять пікселів, тобто рівно на краю прірви.

І тут же розгадка розмиття. У таблиці вище розмиття з σ 2 не заважало при висоті 32. Заміряймо ту саму σ при різних висотах:

висотаσ 0σ 1σ 2σ 3σ 4σ 6
20 px0.00000.00000.09730.77780.88130.9596
32 px0.00000.00000.00000.06700.64571.0000
48 px0.00000.00000.00000.00000.06700.6520

Поріг їде разом із висотою: 2 px при висоті 20, 3-4 px при 32, 6 px при 48. Тобто розмиття — не окрема причина, а та сама причина, записана інакше. Значення має не σ у пікселях, а σ у частках товщини штриха. Обидві поломки — і дрібний шрифт, і розмиття — це один і той самий дефіцит: у літери лишилось замало пікселів, щоб її форма ще була формою.

Що справді заважає читанню

Обери спосіб псування й силу. Ліворуч — як після цього виглядає цифра, праворуч — заміряний CER на всіх рівнях цього псування, поточний рівень підсвічено.

чим псуємо
заміряний CER—
найгірше в цьому ряду—
Контраст можна довести до 2 % — стовпчики лишаються нульовими на всіх шести рівнях; 2 % тут — найнижчий заміряний рівень, а не той, на якому читання ламається (арифметична межа — 0.4 %, коли розмах округлюється до нуля). А висоту досить опустити до 8 px, щоб CER стрибнув до 0.6187. Числа заміряні рушієм tesseract 5.3.2 на трьох різних рядках; картинка ліворуч намальована власним растровим шрифтом, тому вона ілюструє псування, а не є його джерелом.

АлфавітАлфавіт — частина моделі

Найсильніший результат розділу отримується без жодного псування картинки. Візьмімо той самий бездоганний знімок і змінімо одну літеру в налаштуванні: замість lang="ukr" напишімо lang="eng".

ukr: «Продам телефон 4500 грн» eng: «Npogam Tenecbou 4500 rpH»

Українською CER рівно 0.0000, англійською — 0.7045 у середньому по трьох рядках, тобто зіпсовано сім символів із десяти. Це найбільше псування з усіх, які ми міряли, і воно не зачепило жодного пікселя.

Подивись, що саме сталося. Рушій не «не зміг» — він чесно віддав найкращу відповідь із тих, що йому дозволено вимовити. Кирилиці в англійському алфавіті немає, тож кожен гліф розклався на схожі латинські:

булосталощо сталося
ПNнайсхожіша латинська літера
дgхвостик униз збігся
фcbодна літера стала двома
иuперевернута форма

Третій рядок найважливіший. «ф» — це коло з вертикальною рискою; латиниця такого гліфа не має, зате має пару c плюс b, яка разом виглядає схоже. Модель змінила довжину рядка, щоб краще пояснити пікселі. Це не збій — це саме те, чого від неї вимагали.

Алфавіт — не косметична опція, а частина моделі. Він задає простір можливих відповідей, і модель ніколи не вийде за його межі, хай би що було на картинці. Той самий закон діє й у нашій власній мережі: у неї на виході рівно одинадцять класів — десять цифр і порожній символ, — і жодної літери вона не віддасть навіть теоретично.

Практичний наслідок буденніший, ніж здається. Найчастіша причина, з якої OCR «не працює», — не архітектура й не якість знімка, а неправильно виставлений алфавіт: у документі є українська, а рушій налаштований на англійську; у полі самі цифри, а рушій дозволяє собі літери й читає «0» як «O».

ШумШум немонотонний, і один замір бреше

Лишилося останнє псування — гаусів шум. Тут легко зробити висновок, протилежний до правди, і зробити його чесно: досить заміряти один рівень.

σ шумусередній CERнайкращий прогіннайгірший
0.100.00000.00000.0000
0.200.00700.00000.0323
0.250.03530.00000.0870
0.300.68600.04351.0000
0.350.68710.04351.0000
0.400.59610.04351.0000
0.500.41280.06451.0000
0.600.14810.09680.2609

Крива не монотонна. Найгірше рушієві не при найбільшому шумі, а посередині: 0.6871 при σ 0.35 і лише 0.1481 при σ 0.60. Якби ми заміряли один рівень — скажімо, σ 0.60 — то написали б «шум майже не заважає» і мали б рацію рівно в тій одній точці.

Пояснення просте, щойно подивитись на самі відповіді. При середньому шумі рушій вигадує зайві сегменти: шумні плями на папері він приймає за розділові знаки й повертає довгий рядок сміття, у якому справжні літери потонули. При великому шумі він здається раніше й повертає короткий рядок або порожнечу — а короткий помилковий рядок коштує в CER менше, ніж довгий.

Правило, ширше за цю тему. Розкид між зернами тут величезний: при σ 0.35 найкращий прогін дає 0.0435, найгірший — 1.0000, тобто різниця у 23 рази на тому самому рівні шуму. Одне число без розкиду в такій ситуації не є вимірюванням. Це та сама вимога, що в блоці 5: три зерна на кожну точку, і різниця, менша за розкид, не є різницею.

МодельCRNN: смужка ознак замість літер

Тепер збудуємо власну модель — і побудуємо її так, щоб кроку «поділ на символи» в ній не було взагалі. Класична відповідь зветься CRNN (convolutional recurrent neural network) і складається з трьох частин.

згорткова частина

Звичайні згортки з пулінгом, як у блоці 2. Але пулінг несиметричний: по висоті стискаємо агресивно, по ширині майже ні. З картинки 16×40 виходить карта висотою 4 і шириною 20. Далі висоту згортаємо в канали, і лишається смужка з 20 колонок, у кожній — вектор ознак.

рекурентна частина

Двонапрямлений GRU проходить смужку зліва направо й справа наліво. Навіщо: колонка сама по собі бачить лише вертикальну скибку літери, а сусідні колонки кажуть, що це за літера цілком. Двонапрямленість потрібна тому, що початок літери пояснюється її кінцем не менше, ніж навпаки.

лінійний вихід

У кожній із T колонок — розподіл над алфавітом плюс один зайвий символ. У нас це 10 цифр + 1 = 11 класів. Про зайвий символ — увесь наступний розділ.

Уся мережа — 57 131 вага, і на картинці 16×40 вона дає смужку з T = 20 колонок. Головне в ній не розмір, а те, чого в ній немає: ніде не сказано, де на картинці кожна цифра. Розмітка складається з пари «картинка → рядок», і все.

Але тоді виникає питання, яке й тримає всю тему. Мережа віддає 20 колонок, а правильна відповідь має, скажімо, чотири символи. Як порівняти 20 з 4, не знаючи, яка колонка якому символу відповідає?

CTCПорожній символ і склеювання повторів

Відповідь зветься CTC (connectionist temporal classification). Ідея в тому, щоб не вибирати одне вирівнювання, а підсумувати по всіх.

Спершу правила перетворення. Мережа віддає шлях — по одному символу на кожну з T колонок. Шлях перетворюють на рядок двома діями, строго в такому порядку:

  1. склеїти сусідні однакові символи в один;
  2. викинути порожні символи.
– 5 5 – – 1 – → склеїли → – 5 – 1 – → викинули → «51» 5 5 5 5 1 1 – → склеїли → 5 1 – → викинули → «51» – 5 – 5 – – – → склеїли → – 5 – 5 – → викинули → «55» 5 5 5 5 5 5 5 → склеїли → 5 → викинули → «5»

Тире тут — порожній символ (blank). Тепер видно, навіщо він потрібен, і це не «символ пробілу».

Склеювання повторів потрібне тому, що одна цифра займає кілька колонок смужки. Мережа природно скаже «пʼятірка, пʼятірка, пʼятірка» на трьох сусідніх колонках, і без склеювання ми прочитали б «555» замість «5».

Порожній символ потрібен тому, що після склеювання не можна записати справжній повтор. «55» вимагає порожнього між двома пʼятірками — інакше склеювання зʼїсть другу. Порожній символ означає не «пробіл», а «тут щойно нічого не закінчилось»: він розділяє дві сусідні однакові літери й заповнює колонки, у яких немає нічого цікавого.

Скільки колонок треба

Звідси випливає обмеження на довжину смужки. Щоб записати рядок довжини L, потрібно щонайменше L колонок, а на кожну пару сусідніх однакових символів — ще по одній під порожній:

T ≥ L + (кількість сусідніх однакових символів)

У найгіршому випадку, коли всі символи однакові, це дає T ≥ 2L − 1. Порахуймо на прикладах:

рядокLповторівнайменше T
45202
450303
455314
555325
55555549
Обережно з поширеним записом «T ≥ 2L+1». Число 2L+1 справді зʼявляється в описі CTC — це довжина розширеної послідовності, у якій порожній символ вставлено між усіма символами й на обидва краї. Але шлях не мусить проходити через усі її стани: він може перескакувати порожні, коли сусідні символи різні, а закінчитись має в одному з двох останніх станів. Тому справжня межа — 2L − 1, і навіть її досягає лише рядок із самих однакових символів. Для «45009» довжини пʼять досить шести колонок, а не одинадцяти.

Скільки шляхів ведуть до одного рядка

Сітка станів: рядки — розширена послідовність (порожній, символ, порожній, символ…), стовпці — колонки смужки. Число в кружку — скільки шляхів приводять сюди. Праворуч унизу — відповідь.

який рядок записуємо
повторів—
найменше T—
шляхів—
Порівняй «450» і «455» при T = 3. Довжина однакова, а шляхів у першому випадку один, у другому — жодного: між двома пʼятірками мусить стояти порожній, і третьої колонки на нього не вистачає. Тепер постав T = 12 — шляхів стають тисячі, і саме їх усі підсумовує втрата CTC на кожному кроці навчання.

Тепер найголовніше. Імовірність рядка за CTC — це сума ймовірностей усіх шляхів, що в нього склеюються:

p(рядок) = Σ p(шлях) по всіх шляхах, що дають цей рядок

Символ Σ (велика грецька «сигма») означає «додай усе, що стоїть праворуч». Втрата CTC — це мінус логарифм цієї суми, і навчання просто збільшує сумарну ймовірність усіх правильних вирівнювань одразу, не обираючи між ними.

Шляхів багато — для рядка «45009» при T = 20 їх 1 961 256, — але рахувати їх поодинці не треба: та сама сітка з інтерактиву дозволяє порахувати суму за T кроків динамічним програмуванням. Ми перевірили це прямо: перебрали всі 4096 шляхів на маленькому прикладі й дістали 0.0078335876 проти 0.0078335870 у nn.CTCLoss — збіг до сьомого знака.

НавчанняВікно за швидкістю навчання

А тепер поламане місце, через яке CTC має репутацію примхливої втрати. Навчімо мережу тричі — з різною швидкістю навчання, по три зерна на кожну.

швидкість навчаннярядок цілкомCERчастка порожніх відповідей
3e-20.0333 ±0.0471 [0.0000..0.1000]0.7287 ±0.2319 [0.4334..1.0000]0.3367
3e-31.0000 ±0.00000.0000 ±0.00000.0000
3e-40.0000 ±0.00001.0000 ±0.00001.0000

По обидва боки від робочого вікна стоїть той самий результат — порожній рядок. Замалий крок не дає мережі вибратись із порожнього символу взагалі: усі три зерна віддають порожнечу на всіх двохстах перевірних картинках, і CER виходить рівно 1.0000. Завеликий збиває навчання так, що частина зерен теж падає в порожнечу.

Це не «підбери швидкість навчання» — це властивість самої втрати, і видно її на кривій.

епоха lr 3e-2 lr 3e-3 lr 3e-4 0 4.3630 6.0495 8.9267 5 2.7280 2.7261 3.3077 ← усі три на полиці 10 2.7118 2.6955 2.7653 15 2.7260 2.6392 2.7505 20 2.6780 2.3210 2.7405 ← робоча крива рушає 30 2.6077 0.8300 2.7212 40 2.3226 0.0425 2.6946 59 1.7410 0.0097 2.4828

Дивись на робочу криву (3e-3) між четвертою й тринадцятою епохою: за десять епох втрата впала на 0.0963. У таблиці ці дві епохи не надруковані, зате поруч видно те саме на круглих числах — з 2.7261 на пʼятій до 2.6392 на пʼятнадцятій, і це полиця, яка не випадкова.

Полиця і є колапс у порожній символ. Уяви, що модель нічого не знає. Який вихід дає найменшу втрату найдешевше? Порожній рядок. Порожній символ можна поставити в усі T колонок одразу, це не вимагає розрізняти цифри, і втрата від цього одразу помітно падає. Мережа спершу вчиться мовчати, сидить у цьому мінімумі десяток епох — і аж тоді починає розрізняти цифри.

Наслідок практичний і неприємний: читач, який вимкне навчання на пʼятнадцятій епосі, побачить втрату 2.6392 і рівну лінію позаду — і вирішить, що CTC не працює. А при замалому кроці полиця й не закінчується — остання втрата 2.4828 проти 0.0097 у робочого режиму.

Полиця на кривій втрати

Три криві — три швидкості навчання. Повзунок ставить епоху, на якій ти вирішив зупинитись і зробити висновок.

швидкість навчання
втрата тут—
впало за 10 епох—
рядок цілком у кінці навчання—
Зупинись на 15-й епосі при робочій швидкості — і вердикт скаже те саме, що сказав би ти: нічого не рухається, модель мовчить. А до кінця навчання та сама мережа читає рядки цілком. Крива замалого кроку відрізняється від неї тільки тим, що з полиці так і не сходить.
Вікно зсувається разом із розміром пакета. Наші межі — 3e-2 згори й 3e-4 знизу — заміряні при пакеті 64. При меншому пакеті кроків за епоху більше, і те саме навчання проходить при меншій швидкості. Тому не перенось ці числа на свою задачу як константи: переноситься сама форма — вузьке вікно, і порожній рядок по обидва боки від нього.

ДекодуванняЖадібний шлях проти найімовірнішого рядка

Мережа навчена. Лишилось перетворити двадцять колонок на рядок — і тут ховається остання пастка теми.

Жадібне декодування (greedy decoding) бере в кожній колонці найімовірніший символ і склеює те, що вийшло. Це один рядок коду й майже завжди правильна відповідь.

Але воно неправильне за побудовою, і зрозуміти чому важливіше, ніж уміти його замінити. Жадібний вибір знаходить найімовірніший шлях. А нас цікавить найімовірніший рядок, і ймовірність рядка — це сума по всіх його шляхах. Рядок може складатися з мільйона посередніх шляхів і перемогти один блискучий.

Ось найменший приклад, де вони розходяться. Три класи: порожній, «0» і «1». У кожній колонці порожній трохи ймовірніший за кожну окрему цифру — але цифра «0» може прийти багатьма шляхами, а порожній рядок лише одним.

Найімовірніший шлях проти найімовірнішого рядка

Таблиця ймовірностей: рядки — класи, стовпці — колонки смужки. Жовтим — жадібний вибір. Праворуч — усі рядки, які взагалі можна отримати, з їхньою повною ймовірністю.

жадібне—
його p—
найкращий рядок—
його p—
Підніми ймовірність порожнього до 0.40 — жадібний вибір замовкне, бо в кожній окремій колонці порожній сильніший. А сумарно рядок «0» лишається ймовірнішим, бо до нього веде багато шляхів, а до порожнечі — рівно один. Збільшуй T — і відставання жадібного росте у рази: при T = 2 найкращий рядок імовірніший за жадібний удвічі з половиною, при T = 6 — у тридцять разів. Самі ймовірності при цьому падають обидві (шляхів більшає, і кожен окремий стає рідшим), тож дивись на відношення, а не на різницю. Причина та сама: шляхів до непорожнього рядка більшає, а шлях до порожнечі так і лишається один.

Правильна відповідь зветься beam search — і в курсі ми лишаємо цю назву латиницею, бо звичний переклад «променевий пошук» вводить в оману: нічого променевого там немає. Ідея проста: вести не один шлях, а кілька десятків найкращих рядків одразу, додаючи ймовірності шляхів, що зійшлися в один рядок. Ми перевірили його на навченій мережі при дедалі більшому шумі.

σ шуму на перевірціжадібне CERCER із beam searchрозійшлись рядків
0.080.00000.00000 зі 100
0.250.33420.33173 зі 100
0.400.78050.77566 зі 100
0.550.91270.91026 зі 100

Результат чесний і не дуже мальовничий: beam search майже нічого не дає. Він міняє відповідь щонайбільше на шести рядках зі ста, а CER рухається в третьому знаку.

Так і має бути, і ось чому це варто знати. beam search виграє не сам по собі, а разом зі словником. У справжньому OCR до ймовірності шляху додають імовірність слова з мовної моделі, і тоді він може обрати менш імовірний шлях заради більш імовірного слова: «телефон» замість «телефом». Без словника він просто акуратніше рахує те саме — і чесна різниця в третьому знаку. Тому «додати beam search» без мовної моделі — це та сама марна робота, що «почистити картинку» з розділу 5.

ПідсумокЩо з цього забрати

Тема почалась із задачі, яка виглядала дрібною, і закінчилась чотирма твердженнями, жодне з яких не про архітектуру.

І окремо — те, що ця тема дала курсу вперше. Досі ми будували все самі й порівнювати не було з чим. Тут поруч стояв промисловий рушій, і виявилось, що питання до нього ставляться ті самі, що до власної моделі: який у тебе алфавіт, скільки пікселів на літеру, як ти ділиш рядок на смуги. Готова модель не скасовує цих питань — вона лише відповідає на них за тебе, і мовчки.

Куди йдемо далі

Блок 7 додає до знімка два виміри, яких у курсі ще не було. Ця тема була про те, що робити, коли відповідь не картинка. Наступні три — про те, що робити, коли вхід не одна картинка.

Тема 34 додає час: той самий предмет на сусідніх кадрах має лишитись тим самим предметом, і зʼявиться нова метрика — підміна номера. Ти вже бачив у цій темі, як помилка детектора руйнує все далі по конвеєру; у трекінгу це стане головним сюжетом. Тема 35 питає, що з часом робити далі: як класифікувати не кадр, а рух. А тема 36 додає глибину — і разом із нею похибку, яку вперше в курсі міряють у метрах, а не в безрозмірних частках.