Laura Turner O’Hara

スキャン画像をテキストデータに変換する光学的文字認識(Optical Character Recognition; OCR)は、歴史研究にとって天からの贈り物であることは明らかです。このレッスンでは、OCRでテキスト化されたデータをより使いやすくする方法を学びます。

目次

はじめに

スキャン画像をテキストデータに変換する光学的文字認識(Optical Character Recognition; OCR)は、歴史研究にとって天からの贈り物であることは明らかです。この処理により、テキストを検索可能なものにしつつ、より簡単に構文解析しテキストマイニングを行えるようになります。しかし、史料に対してOCRは完璧とは程遠いことは誰もが気付いているはずです。古い書体や形式は独特のOCRを必要とします。例えば、第50回合衆国連邦議会のCongressional Directory(1887)にある次のページを見てください。HeinOnline[訳注:HeinOnlineは法学関係資料の有償オンラインベース。アメリカの官報等も提供している。]からダウンロードしたPDFスキャンは、まとまっているように見えます:

これはPDFページのスクリーンショットです。

しかし、OCRレイヤー(テキストファイルとしてダウンロード*)を見ると、テキストデータがそこまできれいではないことが分かります。

これはOCRのスクリーンショットです。

注: テキストファイルのダウンロードオプションがなければ、pdfminerモジュールを使えばPDFからテキストを抽出することができます。

これを使って19世紀後半の国会議員のワシントンでの住所をマッピングしたいのですが、このデータをより活用しやすくするためにはどうすればよいでしょうか?

答えは正規表現、”regex”です。正規表現によって次のような結果が得られました。これは「本当の」CSVファイルではないものの(カンマが正しくありません)、エクセルで簡単に確認でき、ジオコーディングに向けて準備することができます。前のテキストファイルよりはずっと良いですよね?

Aldrich, N. W,Providence, R. I
Allison, William B, Dubuque, Iowa,24Vermont avenue,
Bate, William,Nashville, Ten, Ebbitt House
Beck, James B,Lexington, Ky
Berry, James I, Bentonville, Ark, National Hotel,
Blair, I lenry \V, Manchester, N. H,2o East Capitol stree_._’
Blodgett, Rufus,Long Branch, N. J
Bowen, Thomas M,Del Norte, Colo
Brown, Joseph E, Atlanta, Ga, Woodmont Flats,
Butler, M. C,Edgefield, S. C, 1751 P street NW
Call, Wilkinson, Jacksonville, Fla, 1903 N street NW
Cameron, J. D,Harrisburg, Pa, 21 Lafayette Square,
Chace, Jonathan,Providence, R, I
Chandler, William E, Concord, N. H, 1421 I street NW
Cockrell, Francis M,Warrensburgh,Mo, I518 R street NW
Coke, Richard,Waco, Tex, 419 Sixth street NW
Colquitt, Alfred I I,Atlanta, Ga, 920 New York avenue
Cullom, Shelby M,Springfield, Ill, 1402 Massachusetts avenue
Daniel, John W,,Lynchburgh, Va, I7OO Nineteenth st. NW
Davis, Cushman K, Saint Paul, Minn, 17oo Fifteenth street NW
Dawes, Henry L,Pittsfield, Mass, 1632Rhode Island avenue.
Dolph, Joseph N,Portland, Oregon, 8 Lafayette Square,
Edmunds, George F, Burlington, Vt, 2111 Massachusetts avenue
Eustis, James B,,New Orleans, La, 1761 N street NW
Evarts, William M,New York, N. Y, i6oi K street NW
Farwell, Charles B, Chicago, Ill,
Faulkner, Charles James, Martinsburgh, W. Va,
Frye, William P,Lewiston, Me, Hamilton House,
George, James Z,Jackson, Miss, Metropolitan Hotel
Gibson, Randall Lee, New Orleans, La, 1723 Rhode Island avenue.
Gorman, Arthur P, Laurel, Md .,1403 K street NW
Gray, George,Wilmington, Del,
Hale, Eugene,Ellsworth, Me, 917 Sixthteenth st. NW
Hampton, Wade, Columbia, S. C,
Harris, Isham G, Memphis,Tenn, 13 First street NE
Hawley, Joseph R,Hartford, Corn, 1514 K street NW
Hearst, George,San Francisco, Cal,
Hiscock, Frank, Syracuse, N. Y, Arlington Hotel
Hoar, George F, Worcester, Mass, 1325 K street NW
Ingalls, John James, Atchison, Kans, I B street NW
Jones, James K,Washington, Ark, 915 M street NW
Jones, John P,Gold Hill, Nev
Kenna, John E,Charleston, W. Va, 14o B street NW
McPherson, John ,Jersey City, N. J, 1014 Vermont avenue,
Manderson, CharlesF. Omaha, Nebr,The Portland
Morgan, John T,.Selma, Ala,I 13 First street NE
Morrill, Justin S, Stratford, Vt, x Thomas Circle

正規表現(Regex)

正規表現は、いわゆるプログラミング言語ではありません。そうではなく、様々なプログラミング言語で用いられている構文に従っており、一連の文字を使い、テキスト内の正確なパターンを見つけたり置換をしたりします。例えば、このサンプルテキストを使ってみましょう:

Let’s get all this bad OCR and $tuff. Gr8!

1. 次の正規表現を使えば、全ての大文字(L、O、C、R、G)を指定できます:

[A-Z]

2. 次の正規表現を使えば、最初の大文字(L)だけを指定することができます:

^[A-Z]

3. 次の正規表現を使えば、大文字以外の全ての文字を指定することもできます:

[^A-Z]

4. 次の正規表現で、略語「OCR」を指定することもできます:

[A-Z]{3}

5. 次の正規表現を使って、句読点を指定することもできます:

[[:punct:]]

6. 次に示す方法で、全ての句読点、スペース、数字を隔離することもできます:

[[:punct:], ,0-9]

 使用する文字セットはあまり多くはありませんが、パターンが複雑になることがあります。またそれ以上に、配置する場所によって文字が異なる意味となることもありえます。上記の例2と例3の違いを例に挙げてみましょう。例2では、脱字符号 (\^) は、行または文書の最初のパターンを指定することを意味しています。しかし、文字クラス([])の中に脱字符号を書くと、これらの文字セット「以外」という意味になります。

正規表現を理解する最もよい方法は、文字が異なる位置でどのような役目を果たすのかを知り、とにかく練習することです。いろいろ試すことが最善の学習方法なので、正規表現テスターツールを使い、その構文を試してみることをお勧めします。Macユーザーなら、Patterns App(Mac Store、米$2.99)という、リアルタイムで正規表現が何をしているかを可視化してくれる便利なツールもあります。同アプリには、各シンボルの内蔵チートシートも付いてきますが、筆者はどちらかというとこの汎用的(つまりプログラミング言語を横断的に使える)チートシートが、より範囲の広いものと思いました。

Pythonと正規表現

本チュートリアルでは、正規表現Pythonモジュールを用いて、先ほどのCongressional Directoryのテキストファイルの「クリーン」バージョンを抽出していきます。このモジュールの公式ドキュメントは説明が詳細ですので、初心者はよりシンプルな正規表現HOWTOドキュメントを利用するとよいでしょう。。

始める前に覚えておくべき2つのこと

  • 思うに、ある一つの文書をクリーンにしなくてはいけないのであれば、Pythonが最も効率的な方法というわけではありませんsedgrepなどのコマンドラインプログラムが、この種の処理ではより効果的だと思います。(これらのチュートリアルの作成は、grep/sedユーザーに任せます)。私がPythonを採用した理由はいくつかあります。まず、1) Pythonの書き方をよく理解しているからであり、また、2) ミスを簡単に追跡できるようにステップを1つ1つ単一のファイルに書き出してくれるから。そして、3) Congressional Directoryから複数ページをきれいにするため、何度も繰り返し使えるプログラムが必要だから、といった理由です。
  • この文書資料に対するOCRは一貫性のあるものとは言えません(単一ページ内でも複数ページでも)。そのため、このクリーニングチュートリアルの結果は完璧ではありません。ここでの目標は、正規表現に面倒な作業を任せて、開始時のテキストデータよりも整った形式で文書をエクスポートすることです。これにより、住所データのジオコーディングを行う前に、手作業で行わなくてはいけない前処理を大幅に軽減しますが、作業の必要性がなくなるわけではありません。

サンプルPythonファイル

データクレンジングのために作成したPythonファイルは以下の通りです。

 #cdocr.py
 #HeinOnlineのテキストドキュメントから句読点を取り除き、情報を抽出する
  
 #モジュールreをインポートする
 import re
  
 #テキストファイルを開き、そのテキストファイルをリストに読み込む
 with open('../../data/txt/50-1-p1.txt') as ocr:
     Text = ocr.readlines()
  
 #修正後のテキストデータを入れるための空のリストを作成する
 CleanText = []
  
 #インポートしたテキストファイル内の各行について、以下の全パターンに合致するかチェックする
 for line in Text:
     #複数のダッシュを含む行にはデータが含まれていることから、それらの行を検索するようにする
     #--はテキスト内部にダッシュ一つだけをもつ行には一致しない
     dashes = re.search('(--+)', line)
  
     #ダッシュのある行を一致させる
     if dashes:
         #ダッシュを自分で選択したデリミターに置換する
         nodash = re.sub('.(-+)', ',', line)
         #複数のピリオドを消す
         nodots = re.sub('.(\.\.+)', '', nodash)
         #余分な空白の個所を,に置換する
         nospaces = re.sub('(  +)', ',', nodots)
         #アスタリスク(*)を消す
         nostar = re.sub('.[*]', '', nospaces)
         #行の先頭に改行やコンマがあればそれを消す
         flushleft = re.sub('^\W', '', nostar)
         #二重コンマを消す(Evartsの個所)
         comma = re.sub(',{2,3}', ',', flushleft)
         #単語間の空白がない個所を整形する(例えばDawesとManderson)
         #住所に00という表記があるところは、その二重の00をスキップさせる
         caps = re.sub('[A-N|P-Z]{2,}', ',', comma)
         #ピリオドを取り除いて、NEとNWのインディケータを整形する
         ne = re.sub('(\,*? N\. ?E.)', ' NE', caps)
         nw = re.sub('(\,*?N\. ?W[\.\,]*?_?)$', ' NW', ne) #VERBOSE化する
         #ラストネームとファーストネームの間にあるピリオドをコンマに変換(Chace, Cockrellの個所)
         match = re.search('^([A-Z][a-z]+\. )', nw) #VERBOSE化する
         if match:
             names = re.sub('\.', ',', nw)
         else:
             names = nw
            #ループしている間、各行をリストCleanTextに追加する
         CleanText.append(names)
  
 #“フェイク”のCSVファイルに保存する   
 with open('cdocr2/50-1p1.csv', 'w') as fcsv:
     #CleanText内の各行をファイルに書き込む
     for line in CleanText:
         fcsv.write(line) 

コメントをかなり丁寧に付け加えましたので、ここではなぜこのようなコードの構造にしたのかを説明しましょう。また、読みやすくさせるため、長い正規表現を整える別の方法も紹介しましょう。

  • 16-22行目 – 元のテキストファイルでは、データが全て複数のダッシュを含む行にある点に注意してください。ここでのコードは、それらの行を効率的に指定しているのです。re.search()関数を使うことで、複数のダッシュを含む全ての行を見つけ出しています。20行目のif文によって、残りのコードがダッシュを含む行のみを対象に動くようにしています。(これにより、筆者が求めているデータの後に続いている、序文やページ番号の行などすべてを除外できるのです)。
  • 23-40行目– ここでは、余計な句読点を全て取り除き、データ内の必要個所(ラストネーム、ファーストネーム、郵便番号、ワシントンの住所)をcsvファイル内の異なるフィールドに移すという長い処理を行っています。re.sub()関数を使うことで、パターンを別の文字に置き換えることができます。コメントを丁寧に付けているので、それぞれの個所が何をやっているのかが分かるでしょう。これは最も効率的なやりかたではないかもしれませんが、これを1つずつやっていくことで、作業を進めていきながら結果を確認できるのです。私はループを構築しながら、コマンドラインに変数を表示させて各ステップを確認していました。ファイルをコマンドラインで実行する前に、例えば24行目(ダッシュを除去している個所)の後に、(ifループ内で)「print nodash」を追加してみることもできます。そうすれば、更新したいものだけを更新し、望んでいない変更が行われていないよう、各ステップを確認できるのです。
  • 41-46行目 – ここでは少し違うメソッドを使っています。OCRのテキストファイルでは、一部の人名をピリオドにしてしまっています(例えば、Chace.JonathanとChase,Jonathanなど)。このパターンで出現するピリオドを全てカンマへと変更したいのです。そこで、行の頭(¥^)を見て、大文字が1つとそのあとに小文字が複数、そしてピリオドと続く、「^([A-Z][a-z]+\.)」というパターンを探し出しています。パターンで指定した後は、そのパターンに一致する行のピリオドをカンマに置き換える処理をしています。

VERBOSEモードを活用

ほとんどの正規表現は読みにくいです。しかし、39行目と40行目は特に読みづらいのではないでしょうか。あなたのコードを読む人のため(あるいは朝の2時にコードを眺めているあなた自身のため)にも、これらのパターンをどのようにきれいにできるでしょうか?モジュールのVERBOSEモードを使えばよいのです。パターンをVERBOSEモードにすることで、pythonがスペースや#記号を無視してくれるので、パターンを複数の行に分割してそれぞれにコメントを付け加えることができるようになります。ただし、スペースを無視するため、スペースがあなたのパターンの一部である場合はバックスラッシュ(\)でエスケープする必要があることを忘れないでください。また、re.VERBOSEとre.Xは同じである点にも留意してください。

VERBOSEモードで書いた場合の39行目と40行目は以下の通りです。

 #(\,*? N\. ?E.)については同様
 #VERBOSEモードではすべてのスペースをエスケープする必要があります
 ne_pattern = re.compile(r'''
     (               #start group
         \,*?        #look for comma (escaped); *? = 0 or more commas with fewest results
         \ N\.?      #look for (escaped) space + N that might have an (escaped) period after it
         \ ?E        #look for an E that may or may not have an space in front of it
         .           #the E might be followed by another character.
     )               #close group
     $               #ONLY look at the end of a line
 ''', re.VERBOSE)
  
 #(\,*? N\. ?W[\.\,]*?_?)$についても同様
 nw_pattern = re.compile(r'''
     (                   #start group
         \,*?            #look for comma (escaped); *? = 0 or more commas with fewest results
         \ N\.?          #look for (escaped) space + N that might have an (escaped) period after it
         \ ?W            #look for an W that may or may not have an space in front of it
         [\.\,]*?        #look for commas or periods (both escaped) that might come after W
         _?              #look for underscore that comes after one of these NW quadrant indicators
     )                   #close group
     $                   #ONLY look at the end of a line
 ''', re.X) 

上記の例では、re.compile()関数を使って今後も使えるようにパターンを保存しています。VERBOSEモードを利用して作成したpythonコード全体は次の通りとなります:ここで、17-39行でVERBOSEモードのパターンを定義し、変数(ne_patternとnw_pattern)に保存している点に注意してください。これらの変数は65行と66行目のループ内で用いています。


 #cdocrverbose.py
 #HeinOnlineのテキストドキュメントから句読点を取り除き、情報を抽出する

 #モジュールreをインポートする
 import re
  
 #テキストファイルを開き、そのテキストファイルをリストに読み込む
 with open('../../data/txt/50-1-p1.txt') as ocr:
     Text = ocr.readlines()
  
 #修正後のテキストデータを入れるための空のリストを作成する
 CleanText = []
  
 ##後で使うために複雑な部分のVERBOSEパターンを作成する##
  
 #(\,*? N\. ?E.)と同じ
 #VERBOSEモードではすべてのスペースをエスケープする必要があります
 ne_pattern = re.compile(r'''
     (               #start group
         \,*?        #look for comma (escaped); *? = 0 or more commas with fewest results
         \ N\.?      #look for (escaped) space + N that might have an (escaped) period after it
         \ ?E        #look for an E that may or may not have an space in front of it
         .           #the E might be followed by another character.
     )               #close group
     $               #ONLY look at the end of a line
 ''', re.VERBOSE)
  
 #(¥,*? N¥. ?W[¥.¥,]*?_?)$についても同様
 nw_pattern = re.compile(r'''
     (                   #start group
         \,*?            #look for comma (escaped); *? = 0 or more commas with fewest results
         \ N\.?          #look for (escaped) space + N that might have an (escaped) period after it
         \ ?W            #look for an W that may or may not have an space in front of it
         [\.\,]*?        #look for commas or periods (both escaped) that might come after W
         _?              #look for underscore that comes after one of these NW quadrant indicators
     )                   #close group
     $                   #ONLY look at the end of a line
 ''', re.VERBOSE)
  
 # インポートしたテキストファイル内の各行について、以下の全パターンに合致するかチェックする
 for line in Text:
     #複数のダッシュを含む行にはデータが含まれていることから、それらの行を検索するようにする
     #--はテキスト内部にダッシュ一つだけをもつ行には一致しない
     dashes = re.search('(--+)', line)
  
     #ダッシュのある行を一致させる
     if dashes:
         #ダッシュを自分で選択したデリミターに置換する
         nodash = re.sub('.(-+)', ',', line)
         #複数のピリオドを消す
         nodots = re.sub('.(¥.¥.+)', '', nodash)
         #余分な空白の個所を,に置換する
         nospaces = re.sub('(  +)', ',', nodots)
         #アスタリスク(*)を消す
         nostar = re.sub('.[*]', '', nospaces)
         #行の先頭に改行やコンマがあればそれを消す
         flushleft = re.sub('^\W', '', nostar)
         #二重コンマを消す(Evartsの個所)
         comma = re.sub(',{2,3}', ',', flushleft)
         #単語間の空白がない個所を整形する(例えばDawesとManderson)
         #住所に00という表記があるところは、その二重の00をスキップさせる
         caps = re.sub('[A-N|P-Z]{2,}', ',', comma)
         #ピリオドを取り除いて、NEとNWのインディケータを整形する(上で定義したVERBOSEモードを利用する)
         ne = re.sub(ne_pattern, ' NE', caps)
         nw = re.sub(nw_pattern, ' NW', ne)
         #ラストネームとファーストネームの間にあるピリオドをコンマに変換(Chace, Cockrellの個所)
         match = re.search('^([A-Z][a-z]+¥.)', nw)
         if match:
             names = re.sub('¥.', ',', nw)
         else:
             names = nw
          #ループしている間、各行をリストCleanTextに追加する
         CleanText.append(names)
  
 #“フェイク”のCSVファイルに保存する   
 with open('cdocr2/50-1p1.csv', 'w') as fcsv:
     #CleanText内の各行をファイルに書き込む
     for line in CleanText:
         fcsv.write(line) 

最後に、正規表現は臆病者のためのものではないことを付け加えておきます。正規表現はパワフルなツールです。あなたのデータを完全に破壊できるほどパワフルなのです。ですので、実際に正規表現を行う場合は、必ずファイルをコピーして練習し少しずつ慣れていきましょう。


著者について

Laura Turner O’Hara氏はU.S. 米国衆議院歴史部(Office of the Historian)に勤務しています。


引用の際はこちらをご利用ください

<原著>
Laura Turner O’Hara, “Cleaning OCR’d text with Regular Expressions,” The Programming Historian 2 (2013), https://programminghistorian.org/en/lessons/cleaning-ocrd-text-with-regular-expressions.

<翻訳記事>
Laura Turner O’Hara著. 菊池信彦訳. 正規表現を利用したOCRテキストのクリーニング手法, 東アジアDHポータル. 2020. https://www.dh.ku-orcas.kansai-u.ac.jp/?p=75.

クリエイティブ・コモンズ・ライセンス
この 作品 は クリエイティブ・コモンズ 表示 4.0 国際 ライセンスの下に提供されています。