Введение
Для проведения проверки мне необходимо было установить адреса нескольких сотен объектов недвижимости. Проблема в том, что адреса были написаны в разных частях документов, документы имели различные форматы, и сам адрес также мог быть написан разнообразными способами.
Да, существует возможность использовать для данной задачи различные библиотеки и сервисы, но источники данных с информацией об этих объектах должны быть упорядочены и однородны. Можно ли используя минимум ресурсов решать подобные задачи? Можно! Рассмотрим решение на основе Python 3, Pandas и нескольких библиотек для конвертации файлов в датафреймы.
Конечно, удобнее работать с данными, представленными в виде упорядоченных списков адресов, геотэгов и пр. (изображение 1). В нашем случае в структуре адресов:
использованы различные разделители (точки, запятые, прочие разделительные знаки)
в строках с адресами присутствовала «мусорная» информация, не относящаяся к адресу
адрес может был представлен как в полном формате (страна, район/область, населённый пункт, улица, строение, помещение), так и в любом сокращённом (изображение 2)
Относительно небольшого набора документов ситуация не представляется проблемной, все нужные данные можно собрать вручную, унифицировать разделители и обрезать всё лишнее. Но что если таких файлов много?
Извлечение информации
Итак, у нас в наличии есть набор файлов, в которых поля с адресами, расположены в различных частях документа. При этом, столбцы неоднородны; данные по адресу могут содержаться как в одном столбце, так и в нескольких; в том числе могут отличаться заголовки столбцов; плюс к каждому из них применяются все свойства проблемной структуры, описанные выше. Все эти нюансы усложняют унификацию процесса сбора информации, но мы постараемся собрать данные воедино.
Следующим шагом с помощью обработчиков для различных видов документов (pdf, excel и word) соберём данные в датафреймы. В случае с файлами Excel, можно воспользоваться встроенными функциями библиотеки pandas (ExcelFile, read_excel). Word - файлы так же не доставят особых проблем (функция Document в библиотеке docx). И в первом, и во втором случае понадобятся небольшие «косметические правки» (поля с датами, спецсимволы и т.п.) и в целом, данные будут готовы для обработки. Наибольшего внимания требуют файлы формата PDF. Из-за своей структуры PDF файлы не позволяют перенести данные напрямую, в следствии чего, приходится обрабатывать документ с учётом его разметки, внутренних изображений, блоков и т.п.
import pandas as pd
import tabula
import fitz
import ast
def sort_blocks(blocks):
# упорядочиваем блоки, т.к. их порядок может быть нарушен (особенности разбора pdf файлов)
sorteded_blocks = []
for b in blocks:
x0 = str(int(b["bbox"][0] + 0.99999)).rjust(4, "0")
y0 = str(int(b["bbox"][1] + 0.99999)).rjust(4, "0")
sort_key = y0 + x0
sorteded_blocks.append([sort_key, b])
return [b[1] for b in sorted(sorteded_blocks)]
def sort_lines(lines):
# аналогично блокам поступаем с лайнами
sorted_lines = []
for l in lines:
y0 = str(int(l["bbox"][1] + 0.99999)).rjust(4, "0")
sorted_lines.append([y0, l])
return [l[1] for l in sorted(sorted_lines)]
def sort_spans(spans):
# аналогично блокам поступаем со спэнами
sorted_spans = []
for s in spans:
x0 = str(int(s["bbox"][0] + 0.99999)).rjust(4, "0")
sorted_spans.append([x0, s])
return [s[1] for s in sorted(sorted_spans)]
def get_data_from_pdf_with_fitz(file):
fitz_document = fitz.Document(file)
fitz_document_pages = fitz_document.pageCount
pdf_data = ""
for page_number in range(fitz_document_pages):
# разбиваем pdf файл на подэлементы и далее каждый на составные части
page_data = ""
page = fitz_document.loadPage(page_number)
page_text = page.getText("json")
# страница делится на блоки
page_dict = ast.literal_eval(page_text)
page_blocks = sort_blocks(page_dict["blocks"])
for block in page_blocks:
if "image" not in block.keys():
# блоки на лайны
page_lines = sort_lines(block["lines"])
for line in page_lines:
# лайны на спэны
page_spans = sort_spans(line["spans"])
for span in page_spans:
if page_data.endswith(" ") or span["text"].startswith(" "):
page_data += span["text"]
else:
page_data += " " + span["text"]
page_data += "\n"
pdf_data += page_data + "\n "
return pdf_data
def get_data_from_pdf(file):
pdf_data = get_data_from_pdf_with_fitz(file)
pdf_data = re.sub(r"[\«\»\"]", " ", pdf_data)
pdf_data = re.sub(r"\s+", " ", pdf_data)
try:
dataframe_container = []
dataframes = tabula.read_pdf(pdf_data, lattice=True, stream=True, pages='all', pandas_options={"header": None})
for n, dataframe in enumerate(dataframes):
for k in range(dataframe.shape[0]):
dataframe_container.append(dataframes[n].values[k])
dataframe = pd.DataFrame(df_container)
return dataframe
except Exception as e:
return e
Альтернативный вариант – передать обработку подобных файлов стороннему ПО, например, ABBYY Fine Reader. В целом, результат будет примерно одинаковый и в большинстве случаев удовлетворительный.
Далее необходимо определить, в какой части документа, а точнее в каких столбцах датафреймов находятся адреса. Если структура документа сохраняется и данные остаются без смещений (т.е. все адреса для каждой строчки остаются в искомых столбцах), можно выделять необходимые столбцы отталкиваясь от их имён (тогда предварительно нужно вручную, либо с помощью какого-то алгоритма собрать список наименований подобных столбцов), в противном случае нужно детектировать адреса используя маску поиска, а затем на основе первой найденной ячейки с адресом выделять и весь столбец, полагая, что в нём так же содержатся адреса.
Import re
address_subwords = [
"Адрес", "АО", "аал", "Аобл\.", "аллея", "аул", "б-р", "волость", "въезд", "высел\.", "г\.",
"городок", "дп\.", "д\.", "дор\.", "ж\/д", "жт\.", "заезд", "заимка", "казарма",
"кв-л\.", "км\.", "кольцо", "край", "кп\.", "линия", "мкр\.", "наб\.", "нп\.",
"обл\.", "остров", "окр\.", "парк", "переезд", "пер\.", "п\/р", "платф\.", "пл-ка",
"пл\.", "полустанок", "пгт\.", "п\/ст\.", "п\.ст\.", "п\.", "починок", "п\/о",
"проезд", "промзона", "просек", "проселок", "пр-кт", "проулок", "рп\.", "рзд\.",
"р-н", "Респ\.", "сал", "с\.", "с\/а\.", "с\/о\.", "с\/с\.", "сквер", "сл\.",
"ст-ца", "ст\.", "стр\.", "тер\.", "тракт", "туп\.", "ул\.", "у\.", "уч-к", "х\.", "ш\."
]
# data - строка для обработки
address = [subword for subword in address_subwords if re.search(r'^%s|\s%s' % (subword, subword), str(data).strip(), flags=re.I | re.M)]
Впрочем, не редко конвертация и/или обработка приводит к ситуации, когда данные смещаются, изменяются. На рисунке (изображение 3) приведен пример документа со смещёнными данными. Данный документ был изначально представлен в виде отсканированного изображения (PDF файл). С помощью программы ABBYY FineReader он был конвертирован в формат excel, но необходимые столбцы с данными были деформированы. В данной ситуации будет недостаточно объединить несколько ключевых столбцов, т.к. заранее неизвестно, что это будут за столбцы, необходимо объединять все ячейки одной строки полностью. Это в свою очередь приведёт к ещё большему загрязнению адреса. Тем не менее на данном этапе у нас в наличии будут списки адресов, которые можно начать обрабатывать.
Обработка информации
Первым шагом для нормализации структуры адреса будет приведение текста к единому стилю. В тексте адреса могут как присутствовать разделители, так и отсутствовать, поэтому базовым вариантом будут считаться адреса без разделителей, очистим от них строку.
Далее начиная от глобальных структур (страна, район, область) будем производить поиск, по ключевым словам, с помощью регулярных выражений, до локальных структур (улица, строение, помещение).
Если ключевые слова не были найдены, значит адрес записан в таком формате, что его невозможно будет отделить от другого текста («мусора»). Тогда мы будем ориентироваться на наличие чисел в строке, означающих строение/помещение, но подобный ход может привести к отбору ненужной информации. Если ключевые слова были найдены, то производится попытка найти ключевые слова рангом ниже (убывание индекса структуры) далее в строке с отсеканием текста из левой части. При этом если следующее ключевое слово не было найдено, но при этом до числового значения (строения/помещения) были слова, проверяется в каком формате они были написаны, т.к. часть адреса, вероятнее всего, будет указана с большой буквы.
import re
def get_address(address):
# address - строка, в которой предположительно находится адрес
address_parts = []
address_index = -1
address_level_n = -1
address_levels_name = ['subject', 'sub_subject', 'settlement', 'street', 'house']
address_levels = [
["респ\.","республика","край","область","обл\.","г\.ф\.з\.","а\.окр\."],
["пос\.","поселение","р-н","район","с\/с","сельсовет"],
["г\.","город","пгт\.","рп\.","кп\.","гп\.","п\.","поселок","аал","арбан","аул","в-ки","выселки","г-к","заимка","з-ка","починок","п-к","киш\.","кишлак","п\.ст\.","ж\/д","м-ко","местечко","деревня","с\.","село","сл\.","ст\.","станция","ст-ца","станица","у\.","улус","х\.","хутор","рзд\.","разъезд","зим\.","зимовье","д\."],
["ал\.","аллея","б-р","бульвар","взв\.","взд\.","въезд","дор\.","дорога","ззд\.","заезд","километр","к-цо","кольцо","лн\.","линия","мгстр\.","магистраль","наб\.","набережная","пер-д","переезд","пер\.","переулок","пл-ка","площадка","пл\.","площадь","пр-кт\.","проспект","проул\.","проулок","рзд\.","разъезд","ряд","с-р","сквер","с-к","спуск","сзд\.","съезд","тракт","туп\.","тупик","ул\.","улица","ш\.","шоссе"],
["влд\.","владение","г-ж","гараж","д\.","дом","двлд\.","домовладение","зд\.","здание","з\/у","участок","кв\.","квартира","ком\.","комната","подв\.","подвал","кот\.","котельная","п-б","погреб","к\.","корпус","офис","пав\.","павильон","помещ\.","помещение","раб\.уч\.","скл\.","склад","соор\.","сооружение","стр\.","строение","торг\.зал\.","цех"]
]
# пытаемся разбить строку на части используя спец-слова и сокращения
# начинаем с верхнего уровня субъектов и спускаемся ниже, если разбивка не получается
for i, level in enumerate(address_levels):
for subword in level:
address_split = re.split(r'%s' % subword, address, 1, flags=re.I | re.M)
if len(address_split) > 1:
clean_subword = re.sub(r'[^\w\.]', '', subword)
# учитывая формат именования верхних уровней, ищем в строке наименование субъекта до ключевого слова
# в противном случае отсекаем часть строки до ключевого слова
if len(address_split[0]) > 0 and i < 2:
address_part_to_append = re.findall(r'[А-Я][^А-Я\d\s]+', address_split[0], flags=re.M)
if len(address_part_to_append) > 0:
address_parts.append(address_part_to_append[-1].strip() + ' ' + clean_subword)
else:
address_split[1] = clean_subword + ' ' + address_split[1].strip()
else:
address_split[1] = clean_subword + ' ' + address_split[1].strip()
address_level_n = i
break
if address_level_n > -1:
break
# в зависимости от разбивки, добавляем предполагаем части адреса к имеющимся, если они имеются
if len(address_split) > 1:
address_parts.extend(address_split[1].split(','))
else:
address_parts.extend(address_split[0].split(','))
# разбиваем строку в поисках улиц, строений, прочих элементов
address_parts_to_split = address_parts
address_parts = []
for address_part in address_parts_to_split:
address_part = address_part.strip()
address_part_split = re.findall(r'(?:[А-Я][^А-Я]+[\s-][А-Я][^А-Я]+)|(?:[А-Я][^А-Я]+)', address_part, flags=re.M)
if len(address_part_split) > 1:
address_parts.extend(address_part_split)
elif len(address_part) > 0:
address_parts.append(address_part)
# поиск обозначения дома и его номера
is_house_finded = False
for house_subword in address_levels[-1]:
house_split = re.split(r'%s' % house_subword, address_parts[-1], 1, flags=re.I | re.M)
clean_house_subword = re.sub(r'[^\w\.]', '', house_subword)
if len(house_split) > 1:
is_house_finded = True
address_parts = address_parts[:-1]
if len(house_split[0]) > 0:
address_parts.append(house_split[0])
address_parts.append(clean_house_subword + ' ' + house_split[1])
# если информация не найдена, добавляем хвост, предполагая, что в нём находится дом
if not is_house_finded:
merged_part = re.search(r'[^\d]\d', address_parts[-1])
if merged_part != None:
n = merged_part.span()[0] + 1
value = address_parts[-1]
address_parts = address_parts[:-1]
address_parts.append(value[:n])
address_parts.append(value[n:])
# собираем данные
dict_address = {}
list_address = list(map(str.strip, address_parts))
for list_address_part in list_address:
if address_level_n < 5:
# ограничиваем размер частей, для избежания больших строк
dict_address[address_levels_name[address_level_n]] = list_address_part[:64]
address_level_n += 1
else:
break
return dict_address
При нахождении числового значения, строка с адресом считается законченной и все данные из правой части отсекаются. Затем все найденные элементы строки собираются в адрес в нормализированном виде. Шаги алгоритма повторяются для каждой строки датафрейма, каждого датафрейма из коллекции.
Кроме описанного алгоритма, я тестово использовал алгоритм, основанный не на ключевых словах, а на формализованном синтаксисе записи адресов в русском языке. Данный алгоритм использует расстояние между символами, расположение больших букв, знаков препинания и числовых значений в конце адреса. В целом работоспособность второго алгоритма оказалась на уровне первого, поэтому в рамках статьи он не обозревается.
Сравнение
Для определения эффективности алгоритма было проведено сравнение с НЛП для поиска и извлечения информации из документов с "плохой" структурой в связке с проектом Natasha. Очевидно, что эффективность последних должна быть выше, но также потребуются данные для обучения, унифицированная структура, приведение к нормальному виду, в то время как описанные в статье алгоритмы работают с необработанными данными.
Основой НЛП для поиска и извлечения информации послужил проект sec-doc-info-extraction. В исходном виде в качестве входных данных он использует html файлы, в которых производится поиск ключевых фраз. Для задачи поиска адресов алгоритм модифицирован - вместо html файлов, обрабатывались excel файлы (соответственно pdf и word документы предварительно сконвертированы в формат excel), а в качестве ключевых слов использовались всё те же элементы структуры адреса. После обучения в целом можно сказать, что скрипт справлялся с поставленной задачей.
Далее, для определения частей адреса внутри строк среди найденных документов, использован метод описанный в статье "Обработка NL адресов для создания эффективных поисковых запросов при помощи набора инструментов проекта Natasha". В отличии от базовой версии Natasha, данный метод позволяет находить большее количество мест за счёт своих доработок (к примеру микрорайоны учитываются как часть адреса). При предварительной обработке данных и приведению их к унифицированному виду (что для тестовой выборки было сделано вручную) задача выполнялась успешно, но в случае с "сырыми" данными, возникала проблема либо с отсечением нужных данных, либо пропуском адреса в принципе, что ожидаемо при использовании "загрязнённых" данных.
Итого, на тестовой выборке получился следующий результат: описанный в статье алгоритм, несмотря на проблемы с полнотой данных, правильно отработал примерно в 70% случаев; в свою очередь модифицированная Natasha с чистыми данными правильно обработала адрес в 95% случаев, но с "плохими" адресами показатель упал до 50% случаев. При этом скорость работы была выше у модификации Natasha (изображение 4).
Вывод:
Несмотря на невысокую скорость работы, зависимость от качества данных, мой алгоритм поиска адресов, при минимальном количестве ресурсов и библиотек, достаточно успешно выполняет эту задачу. А какими способами Вы решаете подобные задачи?