Проект "Data processing"
Как мы делали массовую обработку PDF-документов со сканами с помощью OCR, 2017й год.
Волки с Уолл-стрит
Однажды небольшой, но очень зубастый стартап, занимавшийся примерно тем же самым чем Джордан Белфорт — впариванием торговлей акциями, решил автоматизировать прогнозы и рекомендации для своих клиентов по покупке-продаже.
Для чего понадобилось собирать документы по публичным компаниям с бирж, на которых происходила торговля акциями этих компаний.
Данные документы отражали все основные изменения, происходящие с компанией: покупку-продажу долей, смену директора, изменения в составе акционеров, годовую отчетность по деятельности и так далее.
Если кто вдруг не знал — у любой публичной компании, чьи акции торгуются на бирже в обязательном порядке фиксируются все подобные действия, затем подтверждающие документы выкладываются в публичный доступ.
Все эти документы доступны публично для просмотра и скачивания и обычно представляют собой PDF-документ с отсканированными листами реального бумажного документа.
Но к сожалению текстового слоя в этих документах либо не было совсем, либо он был специально испорчен, чтобы выделение и копирование текста было максимально затруднено.
Или вот так (это отсканированный факс):
Такую страницу документа приходилось «поворачивать» перед началом обработки, для чего угол наклона еще было необходимо угадать.
Встречались PDF-документы, у которых часть страниц была в «портретном» размере, а часть — в «альбомном», в качестве примера:
Как вы наверное уже догадались, эту дичь также приходилось отслеживать и учитывать во время обработки.
Дополнительную пикантность процессу придавало то, что практически все документы были подписаны цифровой подписью и по идее не должны быть доступны для модификации.
Еще поскольку речь про APAC-регион, на некоторых биржах были документы с национальными языками:
Спасало только то, что буквенно-цифровые коды во всех современных языках одинаковые, что и позволяло как-то находить концы и правильно определять данные:
Постановка
Нам необходимо было разработать сервис массовой загрузки и мониторинга, который бы (фактически) в режиме реального времени мониторил выкладки публичных документов по компаниям, забирал обновления и загружал их в реляционную базу с определенной структурой.
Такой класс систем обычно называют ETL, но ввиду специфики задачи и нежелания тратиться на нормальное решение, вместо какой-нибудь Informatica выбрали кастомное решение.
Документов, подлежащих обработке — сотни тысяч, на каждой бирже от 2 до 50 тысяч компаний, по каждой компании от пяти до несколько сотен документов.
Всего планировалось обрабатывать 28 бирж, но реализовать обработку успели лишь для четырех.
Каждый документ на бирже имел заранее согласованную форму, каждая форма имела четкие метки (например заголовок с номером форму), позволявшие отделять мух от котлет и с определенной долей предсказуемости обрабатывать данные.
Реализация состояла из нескольких разных сервисов, отвечавших за свои задачи:
- Document Parser — отвечал за обработку PDF документов и вытаскивание необходимых данных
- Uploader — загружал итоговый результат в реляционную базу, используемую для построения аналитических отчетов
- StockExchange Loader — выкачивал PDF-документы с бирж, вместе с начальными метаданными по каждой компании
- Admin — административный интерфейс управления, с его помощью можно было управлять всей цепочкой
Все решение было развернуто в облаке Amazon и активно использовало его возможности:
очереди сообщений, масштабирование самих сервисов и т.д.
В первую очередь масштабировался сервис обработки документов (Document Parser), поскольку стадия OCR-обработки занимала существенные ресурсы.
Каждая биржа имела свой выделенный загрузчик (StockExchange Loader), со своими скриптами обработки, отвечавшими за взаимодействие с конкретной биржей.
Технологии
В качестве основной технологии мы использовали вполне обыденный Spring и Java, с их помощью реализовывались сами сервисы, API и сетевое взаимодействие.
примерно половина логики в проекте было реализовано в виде скриптов на.. JRuby
Чем достигалось отделение основной части, которая должна была иметь сборку, номер релиза и существенно не меняться в дальнейшем, от программируемой части, которая зависела от внешней среды:
структуры документов, структуры сайтов бирж и так далее.
Да, уши последующих проектов с веб-IDE и скриптами торчат именно отсюда.
Еще мы использовали кучу внешних утилит для препарирования PDF (например снятия ЭЦП) и работы с OCR и изображениями.
Загрузка PDF-документов
Стоит пояснить, что каждая биржа имела и имеет готовое платное API для задач массовой обработки, а также готовые выгрузки всех публичных документов — в заранее подготовленном для обработки виде, без необходимости использования OCR и прочих ухищрений.
Проблема лишь в цене вопроса, поскольку стоят эти выгрузки как самолет неподъемных денег даже для средней компании, не говоря уже о нищем стартапе.
Поэтому для заказчика оказалось дешевле оплатить разработку со всеми ее рисками.
Но вернемся к вопросу загрузки.
Поскольку мы шли «путем пирата» и не могли использовать официальные API, пришлось прикидываться обычным пользователем.
Мы использовали комбинацию из PhantomJS и Selenium для симуляции взаимодействия с биржей от лица пользователя:
переходы по ссылкам, нажатия на кнопки, обработка всплывающих окон и так далее
Вот так выглядел JRuby-скрипт для подключения к австралийской бирже, переход по страницам и начало скачивания документов:
#encoding: utf-8 # регистрация себя для внешнего вызова с стороны платформы Provider.registerAction('ASX','FETCH_NEW','fetchNewASX') # start point, this function will be called on start and periodically # отсюда начиналась обработка def fetchNewASX FetchASXNew.new.parseStart() end; # да, в руби есть классы class FetchASXNew # собственно запуск процесса отбора и скачивания документов def parseStart AppWeb.logInfo(" # -------------------------------[start]----------------------------") # под этим методом был Selenium + Phantomjs AppWeb.connectUrl('http://www.asx.com.au/asx/statistics/prevBusDayAnns.do') if AppWeb.exists("//div/h1[contains(text(),'Technical error')]") || AppWeb.exists("//div/p[contains(text(),'No company announcements have been published')]") AppWeb.saveScreenshot('page_down') AppWeb.exit() end; # Form 603 - Becoming a substantial shareholder AppWeb.logInfo(" # -------------------------------[603]----------------------------") AppWeb.getElements("//tbody/tr/td[contains(text(),'ecoming a substantial holder')]/..") .each{|e|dumpRow e} ...
AppWeb это заранее подготовленный контекст с методами на Java, подсунутый в интерпретатор JRuby.
Обработка PDF-документов
Общая схема обработки документа выглядит следующим образом:
По определенным меткам в документе получаем нужную нам информацию:
не «то что распозналось» или «угадалось» и не все подряд
а исключительно необходимые и актуальные поля.
Если их нет или собранных данных недостаточно — такой документ уходил на ручную обработку оператору, для чего у него был специальный интерфейс.
Вот так выглядел JRuby-скрипт, отвечавший за обработку формы 604:
#encoding: utf-8 # Parsing Form 604 document # регистрация себя для вызова со стороны платформы App.registerProviderMapping('ASX','FORM_604','PDF','parse604') # start function, context will be created and filled outside def parse604(ctx) ctx.requiredFieldsTotal(12) # пропуск логики определения portrait-landscape, # формы 604 никогда не сканировались в альбомном формате ctx.addVar('common-parse-skip-ocr-rotation',1) # та самая метка опредления типа формы # # Если на первых пяти страницах PDF-документа в тексте встречаются # указанные ключевые слова - это форма 604 # found_page = doCommonParse(ctx,0,5, Proc.new {|page| check1 = Text.containsTextAllLines(ctx,ctx.doc().text(page).lines(), 'FORM 604','Corporations Act 2001','Section 671') # can be space between 671 and B check2 = Text.containsTextAllLines(ctx,ctx.doc().text(page).lines(), 'FORM 604','Corporations Law','Section 671') # can be space between 671 and B check1 || check2 }) # check if marker for 604 form was found # if not, can be broken text or first page is a header (like letter) # Проверка что маркер формы был найден, # если найден - запускаем постраничную обработку, начиная с той, # на которой был найден маркер if ctx.results().isMarked('form-data-page-found') Log.info('marker of 604 form was found..') iterateLines604(ctx,found_page) end; return true; end; # собственно обработка формы 604 def iterateLines604(ctx,page) Log.info('iterate for lines, text from: {}',ctx.doc().text(page).getSource().name()) ctx.doc.setCurrentPage(page) # # there are different rules for text was extracted from PDF itself and scanned from image # для OCR и текстового слоя использовались разные правила пост-обработки if ctx.doc.text(page).getSource().name() == 'TESSERACT_OCR' Log.info('process with OCR rules') ctx.doc().text(page).lines().each{|e|parse604LineOCR(e,ctx);ctx.setPrevLine(e)} else Log.info('process with text rules') ctx.doc().text(page).lines().each{|e|parse604Line(e,ctx);ctx.setPrevLine(e)} end; end; # построчная обработка для конкретных полей def add604NoticeDates(line,ctx) # если есть маркер 'holder on' - дата вытаскивается из этой же строки # формат определялся автоматически (эвристикой) if Text.containsText(line,'holder on') Text.addResultSameLine('form604-substantial-holder-became-date', 'holder on',line,'date',ctx) return; end; ... end; # построчная обработка для строки таблицы def add604ChangesInterestsTable(line,ctx) # ряд формируется как набор блоков, каждый из которых # должен соответствовать определенному паттерну, # что позволяет пропускать мусор и снизить количество ошибок row = Text.parseTableLine(line,'[0-9 ./]+','[a-zA-Z ,.\\(\\)\\&]+',"[a-zA-Z \\']+",'[0-9 ,\\$]+','[0-9 ,.]+','[0-9 ,]+') # данные выбираются с учетом типа Text.addResult('form604-date-of-change',row.get(0),'date',ctx) Text.addResult('form604-person-interest-changed',row.get(1),'string',ctx) Text.addResult('form604-nature-of-change',row.get(2),'string',ctx) Text.addResult('form604-consideration-given',row.get(3),'string',ctx) Text.addResult('form604-class-number-of-securities',row.get(4),'string',ctx) Text.addResult('form604-person-votes-affected',row.get(5),'string',ctx) end;
Ниже я по шагам покажу как осуществлялась обработка документа, на примере все той же формы 604 с австралийской фондовой биржи.
В этой форме метки для ее определения (текст «Form 604») всегда находились на первой странице документа:
С документа снималась электронная подпись и затирался слой, отвечающий за вертикальную надпись, поскольку она очень мешала OCR-обработке.
Дальше со страницы вытаскивалась картинка, непосредственно содержащая скан и специальным алгоритмом осуществлялось определение наличие наклона, если он был — картинка наклонялась до полностью горизонтальной.
Следующей стадией картинка увеличивалась в размерах в два раза и с помощью еще одного алгоритма убирались все горизонтальные и вертикальные линии — следы от таблиц.
Промежуточный результат выглядел примерно так:
В таком виде качество распознавания текста было заметно лучше, чем до всех этих ухищрений.
Стоит отметить, что все эти преобразования, описанные выше уже заложены в любом нормальном коммерческом OCR-решении, вроде ABBYY FineReader, нам же пришлось изобретать весь этот велосипед из-за лицензионных ограничений.
В качестве движка для OCR мы использовали широко известный Tesseract OCR, с немного подкрученными весами и словарями.
Но результат разумеется оставался далек от серьезных коммерческих продуктов — чудес нет.
Вот так выглядел результат OCR-обработки для страницы выше:
604 page 1/2 15 July 2001 l =orm 60¢- Corporations Act 2001 ‘ Section 671 B 1 \ol:ice o" c 1ange ol’inl:eres1:s o" su 0stan1:ia 10 c er l E Company Name/Scheme ALE Property Group (ALE) I ACNIARSN ACN 105 275 278 / ARSN 106 063 049 1. Details of substantial holder (1) Name ‘ Woolworths Limited (Woolworths) El ACN/ARSN (if applicable) ACN 000 014 675 There was a change in the interests of the substantial holder on 21/08/2015 The previous notice was given to the company on 14 May 2008 The previous notice was dated 12 May 2008 _\ . . l 2. Previous and present voting power | The total number of votes attached to all the voting shares in the company 0r voting interests in the scheme that the substantial holder or an associate (2) had a relevant interest (3) in when last required, and when now required, to give a substantial holding notice to the company or scheme, are as follows: ! a _ _ Previous notice Present notice f Class of securltles (4) _ , _ ; Person's votes Votmg power (5) Person s votes Voting power (5) l Ordinary shares l 17,076,936 19.9% 17,076,936 8.72% i 3. Changes in relevant interests Particulars of each change in, or change in the nature of, a relevant interest of the substantial holder or an associate in voting securities of the company or ‘ scheme, since the substantial holder was last required to give a substantial holding notice to the company or scheme are as follows: i Person . . Class and Date of whose Nature of Conslderatlon number of Person’s votes glven in relatlon . . , change relevant change (6) to chan e (7) securltles affected ‘ interest g affected l l l l l l Woolworths has neither acquired nor disposed of ALE shares. Woolworths’ Various Woolworths relevant interest has been diluted as a Not applicable Not applicable Not applicable result of a number of share issues by ALE. 4. Present relevant interests Particulars of each relevant interest of the substantial holder in voting securities after the change are as follows: i i Holder of Registered Person entitled Nature of Class and _ relevant holder of to be registered relevant number of Person's votes I interest securities as holder (8) interest (6) securities _ Relevant interest under i section 608(1)(a) of the I Corporations Act 2001 (Cth) 17,076,936 . Woolworths Woolworths Woolworths as the holder of the ALE ordinary shares 17,076,936 shares referred to in this notice. l i i
Тем не менее, этих данных в большинстве случаев было достаточно для сбора необходимой информации.
По данному документу, итоговый результат обработки выглядел следующим образом:
form604-name-scheme=ALE Property Group (ALE) form604-acn-arsn=106063049 form604-substantial-holder-name=Woolworths Limited (Woolworths) form604-substantial-holder-acn-arsn=000014675 form604-substantial-holder-became-date=21-08-2015 form604-previous-notice-given-date=14-05-2008 form604-previous-notice-dated-date=12-05-2008 form604-class-of-securities=Ordinary shares form604-previous-votes=17076936 form604-previous-voting-power=19.9 form604-present-votes=17076936 form604-present-voting-power=8.72
Эпилог
Наверное вам будет интересно узнать чем закончился столь эпичный проект? Закончился он предсказуемо — п#здецом сменой приоритетов:
компания выросла (в плане объемов привлеченных инвестиций) и в какой-то момент ей стали не нужны приседания с собственной разработкой, еще и требующие постоянной актуализации и сопровождения.
Так что примерно через полгода после запуска, все эти радости были свернуты нах#й совсем, вместе со всей аналитикой — готовые отчеты и прогнозы просто покупались с самих бирж.
за деньги вам соберут даже ламборгини, который запустится и поедет
Но как только дело дойдет до осознания коммерческого смысла, внезапно окажется что всегда проще и дешевле купить готовое.