こんにちは、広野です。
最近、データ分析前の前処理として PDF ファイル内のデータを構造化データとして抽出する機会がちょくちょくあり、AWS Step Functions でオーケストレーションしたり、処理するための AWS Lambda 関数をつくったりしておりました。
やりたいことは AWS Lambda で大量の PDF から構造化データを JSON フォーマットで抽出し Amazon S3 に保存。AWS Glue、Amazon Athena で SQL をかけて任意のデータ検索ができるようにしたという話ですが、それは次の機会に。
当初は小さい PDF ファイルが大量にある状況から始めたのですが、その後比較的大きいファイルを処理させたところ処理時間が肥大化してしまったため、PDF解析・テキスト抽出ライブラリ (以下、省略してライブラリ) を変えたことで改善させた、という話です。
この手の対応をよくされている方にとっては当たり前なのかもしれませんが、ライブラリひとつで全然処理時間が異なることに衝撃を受けたため、ブログに書き残しておきます。
要件
以下の要件でいくつかのライブラリを調査、検証し、Python で動く pdfplumber というライブラリで処理を実装していました。
- PDF ファイル内にあるテーブルデータをテキストとして抽出し、その後 JSON データに加工したい。(OCR は不要)
- PDF ファイル内にはヘッダー、フッターがあり、データ抽出前にそれらを座標ベースでトリミング (削除) したい。
- なるべくサーバーレスで実装したい。(AWS Lambda, Amazon S3, AWS Step Functions, Amazon Athena 等を使用)
- ライブラリは商用利用可能。
発生した課題
当初は小さい PDF ファイルをターゲットに検証を進めていましたが、PDF のページ数が増加するにつれ、処理時間が爆発的に増加することに気が付きました。
| ページ数 | サイズ | 処理時間 | メモリ使用量 | 実行環境 | |
|---|---|---|---|---|---|
| 小規模 (当初想定) | 4 | 50 KB | 780 ミリ秒 | 130 MB | Lambda |
| 中規模 | 150 | 500 KB | 43 秒 | 600 MB | Lambda |
| 大規模 | 1000 | 3 MB | 30 分 | 2.4 GB | EC2 (t3a.large) |
- 数値は若干丸めています。
- 計測には、他の短い処理も少しオーバーヘッドとして加算されていますが誤差として扱いました。
- 大規模 PDF は処理時間の関係で Lambda では計測できず、Amazon EC2 インスタンスで計測しました。
pdfplumber だと、Lambda 関数の限界までメモリ量は増やしたとしてもタイムアウトの問題が発生し、大きな PDF は処理できなくなることがわかりました。そもそも 500 KB 程度の PDF 処理にも 43 秒かかっていたこと自体、時間かかりすぎな印象を受けたので、将来スケーリング可能な環境を求めてライブラリの変更を考え始めました。
ライブラリを変えてみた
結論から言いますと、pdf_oxide というライブラリに入れ替えたところ、劇的に処理速度が改善しました。pdfplumber で検証したときと同じ PDF ファイルで同じ出力結果が得られるコードで処理を実施しました。
| ページ数 | サイズ | 処理時間 | メモリ使用量 | 実行環境 | |
|---|---|---|---|---|---|
| 大規模 | 1000 | 3 MB | 19秒 | 1.5 MB | EC2 (t3a.large) |
| pdfplumber | pdf_oxide | 削減効果 | |
|---|---|---|---|
| 処理時間 | 30分 | 19秒 | 94 倍高速化 |
| メモリ使用量 | 2.4 GB | 1.5 MB | 1,600分の1 に削減 |
なお、小規模、中規模ではだいたい 1 – 3 秒で処理が終了しました。比較しづらかったので掲載しませんでしたが、PDF のサイズが大きくなるほど pdf_oxide の方が効率が良くなると考えられます。
移行コード例
pdfplumber から pdf_oxide にコード移行したときの例を紹介します。
PDF の 2 ページ目以降からテキストを抽出する関数です。各ページのヘッダー (上部 10%) とフッター (下部 13%) を座標ベースでトリミングしてから、テキストを取得しています。このトリミングにより、ページ番号や定型の注釈を除外し、取得したいコンテンツデータだけを抽出します。
移行前: pdfplumber
import pdfplumber
import io
HEADER_RATIO = 0.1
FOOTER_RATIO = 0.13
def extract_text(pdf_bytes):
pdf = pdfplumber.open(io.BytesIO(pdf_bytes))
texts = []
for page in pdf.pages[1:]:
h = page.height
cropped = page.crop((0, h * HEADER_RATIO, page.width, h * (1 - FOOTER_RATIO)))
texts.append(cropped.extract_text() or '')
return '\n'.join(texts)
移行後: pdf_oxide
from pdf_oxide import PdfDocument
HEADER_RATIO = 0.1
FOOTER_RATIO = 0.13
def extract_text(pdf_bytes):
doc = PdfDocument.from_bytes(pdf_bytes)
h = doc[0].height
w = doc[0].width
lly = h * FOOTER_RATIO
ury = h * (1 - HEADER_RATIO)
texts = []
for i in range(1, len(doc)):
doc.set_page_crop_box(i, 0, lly, w, ury)
page_text = doc.to_plain_text(i)
lines = [l for l in page_text.split('\n') if l.strip()]
texts.append('\n'.join(lines))
return '\n'.join(texts)
変更点
- pdfplumber.open() → PdfDocument.from_bytes()(io.BytesIO は非対応、bytes を直接渡す)
- page.crop() → doc.set_page_crop_box() + doc.to_plain_text()
- 座標系が異なる: pdfplumber は Y 軸の原点がページ上部、pdf_oxide は PDF 標準の左下原点。ヘッダー除去とフッター除去の座標計算が逆になる点に注意
- pdf_oxide にはテキスト取得 API が複数あり、出力が大きく異なるため注意
まとめ
いかがでしたでしょうか。
サーバーレスは従量課金なので、なるべく処理時間を短くしたいですよね。選択したライブラリひとつでここまで変わるとは思っておらず、良い教訓になりました。
pdf_oxide は比較的新しいライブラリのため、まだ情報が少ないと感じています。AI を利用した開発だとまだ AI が pdf_oxide のリファレンスを十分学習済みでないことがあるので、公式リファレンスを参照させながら開発するのが安全です。検証して用途が合えば非常に高速という恩恵が受けられるので今後も活用していきたいと思います。
本記事が皆様のお役に立てれば幸いです。
