Скрейпинг PDF с нуля на Python: библиотеки tabula-py и Pandas

Специалисту в области науки о данных приходится анализировать данные в любой форме, ведь они хранятся как в специальных SQL-базах, вроде PostgreSQL и MySQL, так и в старой доброй электронной таблице Microsoft Excel. Более того, иногда данные сохранены в нетрадиционном формате, например в PDF

В этой статье вы узнаете, как скрейпить данные из файлов PDF и оформлять их подходящим для применения в Data Science образом с помощью специальных библиотек языка программирования Python.

Оглавление:

  1. Подготовка.
  2. Сохранение структуры данных при скрейпинге из PDF.
  3. Скрейпинг неструктурированных данных из PDF.
    1. Выгрузка данных из файла PDF в объект Pandas.
    2. Cоздание идентификаторов для отдельных записей.
    3. Преобразование таблицы из длинной формы в широкую с помощью Pandas.
    4. Объединение данных в единую финальную таблицу.
  4. Выводы.

Подготовка

Ознакомьтесь со списком необходимых для выполнения руководства Python-библиотек.

  • tabula-py: для скрейпинга текста из файлов PDF.
  • re: для извлечения сугубо нужных данных с помощью регулярных выражений.
  • pandas: для удобной работы с данными.

С помощью стандартного менеджера пакетов pip или любого другого установите две библиотеки из списка:

pip install tabula-py
pip install pandas

Создайте файл программы-скрейпера и импортируйте в него функционал библиотек для скрейпинга:

import tabula as tb
import pandas as pd
import re

Сохранение структуры данных при скрейпинге из PDF

Прежде всего, обсудим скрейпинг данных из файла PDF в изначально структурированном формате. В следующем примере показан скрейпинг таблицы: это хорошо структурированные данные, в них чётко определены строки и столбцы.

Красным обведён набор структурированных (табличных) данных для скрейпинга из файла PDF

Если воспользоваться библиотекой tabula-py, то скрейппинг PDF-данных в структурированном виде упростится в разы. Просто введите в программу расположение табличных данных на PDF-странице, указав верхнюю правую, верхнюю левую, нижнюю правую и нижнюю левую координаты области. Если на странице только одна целевая таблица, то даже не нужно указывать координатами область скрейпинга. Более того, библиотека tabula-py в большинстве случаев определяет строки и столбцы автоматически.

file = 'state_population.pdf'
data = tb.read_pdf(file, area = (300, 0, 600, 800), pages = '1')

Скрейпинг неструктурированных данных из PDF

Во второй части руководства обсудим задачу поинтереснее  —  получение текста из PDF-файла в неструктурированном формате.

Для успешного статистического анализа, визуализации данных и создания моделей машинного обучения просто необходимы “панельные данные” (“лонгитюдные данные”), то есть данные социальных исследований в табличной форме. Однако в 2021-м году многие необходимые для анализа данные доступны только в неструктурированном виде.

Например, сотрудники отдела кадров, скорее всего, хранят исторические данные о заработной плате не в табличной форме. На следующем скриншоте приводятся в пример как раз такие данные о заработной плате со смешанной структурой: в левой части находится информация об имени сотрудника, чистой сумме выплаты, дате выплаты и оплаченном времени, а в правой  —  о категории выплаты (PAY), ставке в час (RATE), количестве часов работы (HRS) и фактической сумме выплаты (AMT).

Красным обведён набор структурированных (табличных) данных для скрейпинга из файла PDF

Для преобразования данных в лонгитюдный (табличный, панельный) формат необходимо выполнить несколько шагов.

  • Шаг 1: выгрузка данных из файла PDF в объект Pandas.

Подобно примеру со структурированными данными, в самом начале скрейпинга воспользуемся методом tb.read_pdf() для импорта неструктурированных, но на этот раз для правильного импорта потребуется вручную указать дополнительные параметры.

file = 'payroll_sample.pdf'
df = tb.read_pdf(file, pages='1', area=(0, 0, 1000, 1000), columns=[200, 265, 300, 320], pandas_options={'header': None}, stream=True)[0]
  • Параметры area и columns

В первой части руководства упоминалось определение области для скрейпинга с помощью координат. Теперь нам в любом случае потребуется вручную указать columns, чтобы определить местоположение всех необходимых для анализа колонок (одна колонка на левой секции и четыре колонки на правой секции).

  • Параметры stream и lattice

Когда в PDF-файле все ячейки таблицы разделены линиями сетки, стоит указать параметр lattice = True для автоматического определения каждой ячейки в таблице. Если же никакой сетки нет, то стоит указать параметр stream = True вместе с параметром columns для определения каждой ячейки вручную.

  1. lattice (bool, необязательный параметр)  —  принудительное извлечение данных из PDF в “решетчатом” режиме. Применяется, когда каждую ячейку таблицы разделяют линейки, как в PDF из Microsoft Excel).
  2. stream (bool, необязательный параметр)  —  принудительное извлечение данных из PDF в “потоковом” режиме. Применяется, когда нет сетки, разделяющей ячейки в таблице.
Пример структурированных (табличных) данных, разделённых с помощью сетки
  • Шаг 2: создание идентификаторов для отдельных записей.

После выполнения предыдущего шага некоторые данные для работы уже получены. Теперь мы воспользуемся библиотекой Python Pandas для манипулирования табличными данными, хранящимися в экземпляре класса pandas.DataFrame  —  это такой специальный “контейнер-таблица” в идеальном для аналитики данных формате.

Для начала создаем столбец, чтобы разместить в нем новые ячейки с идентификаторами записей о сотрудниках. Легко заметить, что имя сотрудника из примера данных (Супермен и Бэтмен) стоит учитывать при определении границы между записью о Супермене и записью о Бэтмене. Каждое имя сотрудника уникально отформатировано: начинается с заглавной буквы и заканчивается строчной. В таком случае для идентификации по имени сотрудника как раз подойдёт регулярное выражение ‘^[A-Z].*[a-z]$’. После поиска регулярным выражением достаточно применить Pandas-функцию cumsum (кумулятивная сумма), а идентификаторы для записей создадутся сами.

df['border'] = df.apply(lambda x: 1 if re.findall('^[A-Z].*[a-z]$', str(x[0])) else 0, axis = 1)
df['row'] = df['border'].transform('cumsum')
Пример создания идентификаторов для записей в таблице: столбец row содержит номер записи (1 и 2)
  • Шаг 3: преобразование таблицы из длинной формы в широкую с помощью Pandas.

Начнём с определения терминов “длинные данные” (“long data”) и “широкие данные” (“wide data”). 

  1. Таблица, хранящаяся в “длинной” форме, содержит по одному столбцу для каждой переменной в системе.
  2. Таблица, хранящаяся в “широкой” форме, распределяет данные о переменной по нескольким столбцам.

Теперь постараемся изменить форму данных в левой и правой секциях таблицы из примера выше.

  1. Для левой секции таблицы создаем новый pandas.DataFrame под названием “employee” (сотрудник), состоящий из столбцов employee_name (имя сотрудника), net_amount (чистая сумма выплаты), pay_date (дата выплаты) и pay_period (оплаченный период). 
  2. Для правой секции таблицы тоже создаем отдельный pandas.DataFrame под названием “payment”, состоящий из столбцов OT_Rate (ставка в час при категории “OT”), Regular_Rate (ставка в час при категории “Regular”), OT_Hours (количество часов работы категории “OT”), Regular_Hours (количество часов работы категории “Regular”), OT_Amt (фактическая сумма выплаты категории “OT”) и Regular_Amt (фактическая сумма выплаты категории “Regular”).

Преобразовать данные в широкую форму нам поможет функция из библиотеки Pandas под названием pivot.

# изменение формы левой секции таблицы
employee = df[[0, 'row']]
employee = employee[employee[0].notnull()]
employee['index'] = employee.groupby('row').cumcount()+1
employee = employee.pivot(index = ['row'], columns = ['index'], values = 0).reset_index()
employee = employee.rename(columns = {1: 'employee_name', 2: 'net_amount', 3: 'pay_date', 4: 'pay_period'})
employee['net_amount'] = employee.apply(lambda x: x['net_amount'].replace('Net', '').strip(), axis = 1)

# изменение формы правой секции таблицы
payment = df[[1, 2, 3, 4, 'row']]
payment = payment[payment[1].notnull()]
payment = payment[payment['row']!=0]
payment = payment.pivot(index = ['row'], columns = 1, values = [2, 3, 4]).reset_index()
payment.columns = [str(col[0])+col[1] for col in payment.columns.values]
for i in ['Regular', 'OT']:
    payment = payment.rename(columns = {f'2{i}': f'{i}_Rate', f'3{i}': f'{i}_Hours', f'4{i}': f'{i}_Amt'})
pandas.DataFrame под названием “employee” (сотрудник), правая секция изначальной таблицы
pandas.DataFrame под названием “payment” (платеж), левая секция изначальной таблицы
  • Шаг 4: объединение данных в единую финальную таблицу.

Наконец, на основе идентификаторов строк с помощью функции merge() объединим два pandas.DataFrame  —  employee и payment  —  в единую таблицу данных о сотрудниках и платежах.

df_clean = employee.merge(payment, on = ['row'], how = 'inner')
Пример финальной таблицы с данными о сотрудниках и платежах

Выводы

В 2021-м году сотрудники многих компаний все еще вручную обрабатывают данные из фалов формата PDF. Сегодня мы показали, как при помощи Python-библиотек tabula-py и Pandas сэкономить время и деньги, автоматизировав не только извлечение данных из файлов PDF, но и преобразование неструктурированных данных в панельные.

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

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Aaron Zhu: Scrape Data from PDF Files Using Python

Предыдущая статьяКак преобразовать функции JavaScript в генераторы, эффективно использующие память
Следующая статьяКомбинаторы парсеров: от parsimmon до nom (Typescript → Rust)