2021年12月10日金曜日

自分のサイトをWebスクレイピング

この記事は、ケーシーエスキャロット Advent Calendar 2021 の10日目の記事になります。

「Webスクレイピングやってみようかな」と思ってから半年以上が経って、すっかり忘れていたところにアドベントカレンダーの案内が。「これは今やらないとタイミングを逃す」ってことで、やってみることにしました。


まずは調査

世の中のWebサイトから必要な情報を抜き出すテクニックが「Webスクレイピング」。Python を使って実現できます。ただ、そんなに自由に他所様のWebサイトから情報を取得して良いのだろうかと心配になります。軽く調べてみたところ、いろいろと意見が分かれているようで、使用目的や収集した情報の利用方法によっては問題になることもあるようです。また、明示的にスクリピングを禁止しているサイトも多々ありますので、法律をかざして反論するよりも、サイトの主張を尊重したいところです。スクレイピングを禁止してWebAPIを公開しているサイトもありますので、そういうサイトに対しては素直に提供されたWebAPIを使うべきだろうと思います。

今回はWebスクレイピングをやってみるのが目的なので、自分の趣味のサイトを自分自身でスクレイピングしてみることにしました。

ゴール設定

【対象サイト】https://holy-garaman.ssl-lolipop.jp/

上記のサイトではマジックに関する色々な情報を整理しています。その中でカードやコインを扱ったテクニックがどの本で解説されているのかについてもまとめています。サイトを立ち上げてから18年くらい経っていますが、この情報の中に一体いくつのテクニックが含まれているのか、自分でも分からなくなっているので、これを機に数えてみることにします。

実際の画面はこんな感じです。


左側のメニューには「Card」と「Coin」の2つのカテゴリーがあって、それぞれに「ア〜オ」から「ワ」まで10個のメニューがあります。カタカナでまとめてあるのはちょっとかっこ悪いのですが、「ライプチッヒ・カラー・チェンジってどの本に載ってたっけな?」と思った時に探し易いのでこうしてあります。アルファベットでまとめた場合は、正しいスペル (Leipzig Color Change) が思い出せないと探せないので。。。

各メニューをクリックするとテクニック名が列挙してあるページに遷移します。つまりカードとコインの技法がそれぞれ10ページに分かれて掲載されていということです。まずは全部で20個の URL を取得してみます。70行未満で書けたので



コード解説


01 #-*- coding:utf-8 -*-
02 
03 import time
04 from selenium import webdriver
05 from selenium.webdriver.chrome.options import Options
06 from selenium.webdriver.common.by import By
07 
08 import chromedriver_binary
09 
10 def main():
11   print("Prepare start")
12   driver = prepareDriver()
13   print("Prepare end")
14  
15   url_list = get_url_list(driver)
16   print(len(url_list))
17   scraping_data(driver, url_list)
18 
19   driver.close()
 :
 :
66 if __name__ == "__main__":
67   main()

まずは main() 関数にやりたいことを書いてしまいます。先ほどのページにアクセスするための WebDriver を準備するために prepareDriver() を呼びます。具体的な処理は後述。続けて get_url_list() で20個の URL を取得することにします。取得した URL リストを渡して scraping_data() を呼ぶと、それぞれの URL に遷移して、テクニックの名前を取得してファイルに残すことにします。ではそれぞれのメソッドを順を追って解説します。まずは prepareDriver()から。


21 def prepareDriver():
22   options = Options()
23   options.add_argument('-headless')
24   driver = webdriver.Chrome(options=options)
25 
26   url = "https://holy-garaman.ssl-lolipop.jp/technique.php"
27 
28   driver.set_page_load_timeout(3)
29   driver.get(url)
30   time.sleep(1)
31
32   return driver
33

今回は selenium を使いました。本来はユーザーが実際にブラウザを操作したかのようにプログラムから操作するための python ライブラリです。テストに使われるのが目的だったようですが、Web スクレイピングにも活用されています。23行目に "-headless" というオプションを付けていますが、これは GUI を持たない状態でブラウザを起動するためのモードを表しています。24行目ではそのオプションを付けた上で Chrome ブラウザを生成しています。29行目で get(url) していますが、これで該当の HTML ページの内容が driver に読み込まれます。

<div id="submenu_wrapper">
  <nav id="submenu">
    <form>
      <label for="material">素材別分類</label>
      <section>
        <label class="icon-card" for="card">
          ::before
          "Card"
        </label>
        <ul>
          <li>
            <a href="/technique/card/a_o.php?sid=card">ア〜オ</a>
          </li>
          <li>
            <a href="/technique/card/ka_ko.php?sid=card">カ〜コ</a>
          </li>
          (省略)
      </section>
    </form>
  </nav>
</div>

上記は driver に読み込まれた HTML のうちメニュー部分の一部を抜粋したところです(自分のサイトだと引用するのも気楽で良いもんです)。このうち必要なのは href=" " の UML 部分だけで、全部で20箇所あります。では driver から URL を取得するメソッドを見てみましょう。

34 def get_url_list(driver):
35   nav = driver.find_element(By.ID, 'submenu')
36   form = nav.find_element(By.TAG_NAME, 'form')
37   section = form.find_element(By.TAG_NAME, 'section')
38   ul_list = section.find_elements(By.TAG_NAME, 'ul')
39 
40   url_list = []
41   for ul in ul_list:
42     for li in ul.find_elements(By.TAG_NAME, 'li'):
43       a_element = li.find_element(By.TAG_NAME, 'a')
44       url = a_element.get_attribute('href')
45       url_list.append(url)
46 
47   return url_list
48

selenium では HTML のタグを順に取得する方法が提供されています。タグの階層構造をそのまま find_element() で追いかけていくだけの記述なので、読めば理解できるレベルだと思います。最後の <a> タグを表す element から get_attribute('href') で該当の URL 部分を抜き出しています。それを url_list に入れて戻り値として返します。

最終的に欲しいデータはこれらの URL の先にありますので、scraping_data() メソッドで順番に開いていきます。

49 def scraping_data(driver, url_list):
50   f = open('technique.txt', 'w')
51   for url in url_list:
52     print(url, flush=True)
53     driver.get(url)
54     time.sleep(1)
55     words = url.split('/')
56     output_data(driver, words[4], f)
57   f.close()
58

取得したテクニック名を全て technique.txt に出力しますので、"w" でオープンしておきます。prepareDriver() で行ったのと同じように、20個の URL を get() して driver に順次読み込みます。読み込むページは次のようなものです。

上記の「ハイドアウト・カウント」や「パケット・フォース」等が、テクニック名です。この部分だけを抽出するためには HTML を解析する必要がありますので、構成を見てみましょう。
<section id="main">
  <article id="withmenu">
    <h1>ハ〜ホ</h1>
    <table>
      <tbody>
        <tr>
          <td class="title">ハイドアウト・カウント</td&td;
          <td>Hide-Out Count</td&td;
        </tr>
        <tr>
          <td colspan="2">カードマジック カウント事典 p.98</td&td;
        </tr>
        <tr>
          <td class="title">パケット・フォース</td&td;
          <td>Packet Force</td&td;
        </tr>
        <tr>
          <td colspan="2">ロベルト・ジョビーのカード・カレッジp.9</td&td;
        </tr>
        (省略)
      </tbody>
    </table>
  </article>
</section>

都合よくテクニック名のところだけ、class="title" がついているので、これを利用します。最後の output_data() メソッドを見れば一目瞭然です。

59 def output_data(driver, type, f):
60   title_list = driver.find_elements(By.CLASS_NAME, 'title')
61 
62   for title in title_list:
63     f.write("[" + type + "] " + title.text + "\n")
64

find_elements() に class 名の 'title" を指定すれば、全てのテクニック名を取得できます。あとは、ファイルに出力するだけです。実際に出力されたファイルを覗くと次のようになっています。

   1 [card] アイ・エー・ゴースト・カウント
   2 [card] アイ・カウント
   3 [card] アウト・ジョグ
   4 [card] アウト・ジョグ・スイッチ
   5 [card] アウト・ジョグ・ハーマン・パス
    :
  (省略)
    :
1003 [coin] ワン・ハンド・サムパーム・ターンノーバー・スイッチ
1004 [coin] ワン・ハンド・スイッチ
1005 [coin] ワン・ハンド・スペクテーター・スリーブ・チェンジ
1006 [coin] ワン・ハンド・ターンノーバー・スイッチ
1007 [coin] ワンハンド・バニッシュ
これで、掲載されているテクニック名は1007個であることが確認できました。

2020年12月25日金曜日

地図上に年表を

この記事は、ケーシーエスキャロット Advent Calendar 2020 の25日目の記事になります。

今年は趣味の「マジック研究」に時間を使うことが多かったので、AdventCalendar に書くことが無いなぁ、なんて思っていました。でも、それは 12/18 のaooki さんの記事を読むまでの話。WMS(地図画像)を Google Map 上にマッピングするというお話を読んで、過去にやりかけていたアイディアを思い出しました。

それは、歴史年表を地図上で表現したら面白いのではないかと思ったのがきっかけでした。世界地図の下にスライダーバーがあるだけのシンプルな画面をイメージしてください。スライダーを右に動かすと時代が進みます。たとえば、スライダーの位置を1926年に合わせると、地図上にはいくつかのポップアップが表示されています。アメリカ・ロサンゼルスあたりのポップアップには「マリリン・モンロー誕生」と書かれていて、イギリス・ロンドンあたりのポップアップには「エリザベス2世(現イギリス女王)誕生」と書かれています(同い年だったとは...)。そして同じポップアップの中には「アガサ・クリスティー失踪」というセンセーショナルな出来事が解説されていたりします。そんなアプリがあったら、スライダーバーを弄りながら色々な発見を楽しめるのではないかと思ったわけです。

1802年のイギリスには「蒸気機関車の発明」。それから少しだけスライダーを進めて1818年に合わせると、ドイツに「自転車の発明」と出てきます。自転車の方が後だという発見もなかなか面白いと思うのです。

とはいえ、あらゆる歴史をデータベースに入力するのはさすがに無理です。せめて各国のマジックの歴史をこのアプリで見られたら良いなぁ、という夢をもち、3年ほど前にちょっと手をつけてみたことがありました。データの準備に相当な労力がいるなぁと思いつつ、忙しくなって途中で放置していたのですが、aooki さんの記事で思い出したので、これを機にキリの良いところまでやってみることにしました。


歴史データ

FISM というマジックの国際大会についてのデータを使うことにします。FISM は1948年にスイス・ローザンヌで第1回が開催され、第5回までは毎年、それ以降は3年に1回のペースで現在まで続いています。ヨーロッパを中心にして開催されますが、第19回は横浜で開催されました。毎回場所が変わるのが今回作るアプリにとっては都合が良いところです。

Leaflet + OpenStreetMapでやってみる

leaflet は Web 上で地図を表示するための Java Script ライブラリです。
https://leafletjs.com/

OpenStreetMap は誰でも自由に使えるオープンな地図データです。
https://www.openstreetmap.org/

できた!

前置きが長くなってしまったので、動かしたときの動画を先に貼っておきます。スライダーの動きに合わせてポップアップが表示される様子がわかります。



コード解説

<head>に以下のように書き、leaflet の CSS と JavaScript ライブラリを読み込んでおきます。PC に何かをダウンロードしたりインストールしたり、という手間は一切ありません。お手軽です。
<head>
    <meta charset="UTF-8">
    <title>Histoly Map</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css">
    <script src="https://unpkg.com/leaflet@1.6.0/dist/leafet.js">&jt;/script>
</head>

<body>には、地図を置くための領域(mapcontainer)と、選択中の年を表示する領域(year)と、スライダーバー(slidecontainer)の3つだけ宣言しておきます。今回はデザインには拘らないので、単純に縦に並べました。
<div id="mapcontainer" style="width: 100%; height: calc(50vw)"></div>
<p id="year"></p>
<div class="slidecontainer">
    <input type="range" min="1948" max="2018" value="1948" class="slider" id="period">
</div>

PHP からデータベースにアクセスして、予め登録しておいたデータを全て取ってきます。(あらかじめ登録するのが一番大変だったわけですが、今回の話のポイントはそこじゃないのでサクッと省略です)
FISM の全てのデータは $data に入っていますが、それを後で JavaScript から使うために JSON に変換し、$data_json としてあります。
<?php
    try {
        $dbh = new PDO('mysql:host=xxxxx;dbname=yyyyy;port=0000;charset=utf8',
                       'account', 'password');
        $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $res = $dbh->query("select xxxx");

        $data = $res->fetchAll();
        $data_json = json_encode($data);
        $min_json = json_encode($dbh->query("select MIN(year) from history")->fetchAll());
        $max_json = json_encode($dbh->query("select MAX(year) from history")->fetchAll());
    } catch (PDOException $e) {
        print "エラー: " . $e->getMessage() . "<br/>";
        die();
    }
?>

いよいよ OpenStreetMap を貼り付けるところですが、非常に簡単です。事前に読み込んだライブラリの中に定義されている L.map()に、地図を表示するための領域(mapcontainer)を渡してインスタンス化します。setView() では東京の座標を指定しました。これで世界地図の中心が東京になります。

また、L.tileLayer() では実際に貼り付ける地図データを指定します。ここではシンプルな白地図を指定してあります。zoom = 2 にすると、ちょうど世界地図の全体が一画面に表示される程度の大きさだったので、これを最小値としました。zoom = 15 まで段階的にズームしていくことができます。
<script>
    const MIN_ZOOM = 2;
    const MAX_ZOOM = 15;
    var mymap = L.map('mapcontainer').setView([35.4122, 139.4130], MIN_ZOOM);

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        minZoom: MIN_ZOOM,
        maxZoom: MAX_ZOOM,
        attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
    }).addTo(mymap);

データベースから取得した全てのデータは $php_json に入っているので、あとはスラーダーバーの位置から西暦を割り出し、その年のデータを地図上にマーカーとして表示するだけです。そしてこれも leaflet が用意してくれています。GPS 座標を指定して L.marker() を作るだけです。
    var js_array = <?php echo $php_json; ?>;

    var slider = document.getElementById("period");
    var min_j = <?php echo $min_json; ?>;
    slider.min = min_j[0][0];
    var max_j = <?php echo $max_json; ?>;
    slider.max = max_j[0][0];
            
    var selected_year = document.getElementById("year");
    selected_year.innerHTML = slider.min;
            
    var markers = [];
    slider.oninput = function () {
        selected_year.innerHTML = slider.value;
        while (markers.length > 0) {
            var marker = markers.pop();
            mymap.removeLayer(marker);
        }
        markers = [];
        for (var i=0; i<js_array.length; i++) {
            var row = js_array[i];
            if (row['year'] < (parseInt(slider.value)-1)) {
                continue;
            }
            if (row['year'] > (parseInt(slider.value)+1)) {
                continue;
            }
            var popupContent = '<img src="' + './images/' + row['banner'] +
                               '">' + '<br><h2>' +
                               row['year'] + " : " + row['event'] + "</h2>" +
                               "<p>" + row['city'] + " (" + row['country'] + ")";
            var marker = L.marker([row['city_latitude'],  
                                   row['city_longitude']]).bindPopup(popupContent);
                    
            var gap = Math.abs(row['year'] - parseInt(slider.value));
            mymap.addLayer(marker);
            switch (gap) {
                case 0:
                    marker.setOpacity(1.0).openPopup();
                    break;
                case 1:
                    marker.setOpacity(0.5);
                    break;
            }
            markers.push(marker);
        }
    };
</script>
これくらいのコードで先程の動画のような動きができるようになります。動きもスムーズでストレスなく使えました。Leaflet + OpenStreetMap の組み合わせ、素晴らしいです。


余談

ところで、地図アプリの性能として、マーカーの位置はどの程度正確に表現されているのかも気になります。ズームすると少しずれていた、なんてことがないか一応確認してみました。第25回のブラックプールの辺りをズームしてみた動画を貼っておきます。zoom = 13 までズームしたところ、ブラックプールの地区の中にポップアップが表示されていることが確認できました。

ちなみに、zoom = 15 までズームすると通りの名前が一通り表示されます。
FISM のデータだけでは何も新しい発見はありませんでしたが、「マジシャンの興行履歴」「主な専門書の発行記録」「クラシックマジックの原案の公表記録」等々、データが増えれば楽しくなるんじゃないかと。多分。。きっと。。。おそらく。。。。

2019年12月24日火曜日

くずし字を読む
(畳み込みニューラルネットワーク)

この記事は、ケーシーエスキャロット Advent Calendar 2019 の24日目の記事になります。

畳み込みニューラルネットワークモデル

前回の「ニューラルネットワーク編」では、文字を見分けるポイントを何も指示していないにも関わらず、80% 以上の正解率を叩き出してくれました。が、、、やっぱりもう少し上を目指したいところです。前回は画像ファイルを 1024 の直列に変換して、単なる数値データの羅列として学習しました。文字はあくまでも2次元平面に書かれた絵のようなものですので、32 x 32 のままで画像特有の特徴を捕まえられるようにすれば、もっと上を目指せるはずです。

今回は「空間フィルタ」「プーリング」「Dropout」を使いますので、それぞれのイメージを説明しておきます。


まずは「空間フィルタ」です。左上の 6 x 6 の図を見てください。数字が大きいほど濃い色になっています。左上の 3 x 3 の空間を赤い枠で囲んでありますが、この中心の値は 1 です。例えば、ピンポイントでこの位置の特徴を考えると、「濃さが1」というだけなのですが、人間が書いた文字の中にそんな小さな点だけを打つことはほぼなくて、その周辺とつながっているはずです。縦線の一部として存在するのか、それとも横線の一部として存在するのか、そんな情報こそが文字としての特徴であるはずです。

上記の図ではそういった特徴を抽出するためにフィルタを用いています。中央の1列だけが1で残りの列が0のフィルタにかけた時の様子です。縦のラインの特徴を拾うためのフィルタなので、上下の 2 と 1 も拾って合計し、この赤枠のエリアには 4 という価値を見出しています。言い換えると、縦ラインを抽出するフィルタを持っているニューロンは、このエリアに強く反応するということです。その隣の 3 x 3 の領域にも同じフィルタをかけると、今度は 3 という価値を見出しています。どちらも中央は 1 なのですが、上下の情報にも影響を受けることになるのです。この処理を画像全体に対して施していくことを「畳み込み」と呼びます。これが畳み込みニューラルネットワークと呼ばれる所以です。

ちなみに、右肩下がりの斜めのフィルタを通した場合は、フィルタ後の結果は大きく変わってきます。たくさんの種類のフィルタを用意して順番に適用することで、どのフィルタで強い特徴が現れたかを知ることができ、それこそが文字の特徴を読み取っていく作業ということになります。


つぎに「プーリング」です。上記の 6 x 6 の図は、人間なら数字の「2」として認識するところです。そして、下の画像も数字の「2」として認識することでしょう。左に寄っているか、右に寄っているかの差がありますが、どちらも「2」と認識するのが普通です。当たり前のようですが、このデータを数値の羅列として捉えてしまうと、かなり違うものとして解釈してしまうかもしれません。

では、上記のように上下左右にずれていても同じものとして認識してもらうにはどうしたら良いのでしょうか。意外とシンプルな方法で実現できます。絵を見ればわかると思いますので、説明は省略します。なお、上記の絵では偶々全く同じ結果になっていますが、実際にはこれ程理想的な結果にはなりません。それでも、かなり近いものとして認識されることにはなります。

最後は、「Dropout」です。機械学習を進めていくと、だんだんと正解率が上がるようになっていきます。しかし、あまり学習しすぎると、用意した学習用のデータに特化した脳が出来上がってしまい、評価用のデータで評価すると散々な結果になる可能性があります。これを過学習と呼びます。 適度な学習を促すための工夫が Dropout です。

より詳しく知りたい方には、以下のページをお勧めします。

【Deep Learning】過学習とDropoutについて
http://sonickun.hatenablog.com/entry/2016/07/18/191656

これらを駆使して、最終的なニューラルネットワークの構造は次のようにしました。


実は、参考にした本に載っていた構造とほとんど一緒です。まずはここから始めて試行錯誤するつもりでしたが、そのままでも良好な結果が得られたので。。。

  1 #!/usr/local/bin/python
  2 
  3 from ImageLoader2 import LoadDataSet
  4 from ShowPrediction import ShowPrediction
  5 import numpy as np
  6 
  7 from keras.models import Sequential
  8 from keras.layers import Dense, Dropout, Flatten
  9 from keras.layers import Conv2D, MaxPooling2D
 10 from keras.optimizers import Adam
 11 from keras.utils import np_utils
 12 import time
 13 
 14 model = Sequential() 
 15 model.add(Conv2D(16, (3, 3), input_shape=(32, 32, 1), activation='relu'))
 16 model.add(Conv2D(32, (3, 3), activation='relu'))
 17 model.add(MaxPooling2D(pool_size=(2, 2)))
 18 model.add(Conv2D(64, (3, 3), activation='relu'))
 19 model.add(MaxPooling2D(pool_size=(2, 2)))
 20 model.add(Dropout(0.25))
 21 model.add(Flatten()) 
 22 model.add(Dense(128, activation='relu'))
 23 model.add(Dropout(0.25))
 24 model.add(Dense(48, activation='softmax'))
 25 
 26 model.compile(loss='categorical_crossentropy', 
 27               optimizer=Adam(), metrics=['accuracy'])
 28 datas = LoadDataSet()
 29 datas.get_data('save.pkl')
 30 
 31 num_classes = 48
 32 
 33 x_train = datas.train_img
 34 y_train = datas.train_label
 35 print(y_train.size)
 36 y_train = np_utils.to_categorical(y_train, num_classes)
 37 
 38 x_test = datas.test_img
 39 y_test = datas.test_label
 40 print(y_test.size)
 41 y_test = np_utils.to_categorical(y_test, num_classes)
 42 
 43 startTime = time.time()
 44 
 45 history = model.fit(x_train, y_train, batch_size=3000, epochs=100,
 46                     verbose=1, validation_data=(x_test, y_test))
 47 score = model.evaluate(x_test, y_test, verbose=1)
 48 print('Test loss:', score[0])
 49 print('Test accuracy:', score[1])
 50 print("Computation time:{0:.3f} sec".format(time.time() - startTime))

14行目で model を作っているのは前回と同じです。この脳に対して、15〜24行目でいろいろな層を追加しています。この辺りは絵に書いた通りの作業です。前回と違う点として、活性化関数が sigmoid から relu に変わっています。sigmoid でもできなくはないのですが、そもそも入力データにマイナスがない(画像データなので)のに、マイナスを考慮した sigmoid を使うのはあまり相応しくないのと、relu の方が python にとっては都合が良く、計算が早くできるというメリットがあるので、こちらを採用しています。

26行目の compile 時に最適化関数として adam を指定しているのは前回と同じです。また、その後のデータロード処理も基本的には一緒です。前回は 1024 の直列データに変換していましたが、それはやらずに 32 x 32 のままで利用します。

45 行目で学習して、47 行目で評価しています。その結果を見てみましょう。



Epoch 100/100 と書かれたところに学習の成果が出力されています。
loss: 0.0668
acc: 0.9792
ですから、前回の投稿の時の結果と比較すると、損失が 0.5358 から 0.0668 に下がり、かなり 0 に近づきました。正解率も85.42% から 97.92% へと上がり、かなり満足のいく学習結果です。とはいえ、評価用のデータで評価した時に大きく成績が悪化するようだと過学習です。早速比較してみましょう。

Test loss: 0.1147308552731136
Test accuracy: 0.9756084471628312
となっていますので、評価用データでも97%台の成績を収めています。これなら良好な結果と言えるのではないでしょうか。

最後に、評価した時の画像を並べてその結果をビジュアルに見てみます。


今回も同じ 96 枚の画像の評価結果を表示しています。この 96 枚に限っては 100% の正解率になりました。右上の「ふ」は、前回読めなかった天ぷら屋さんの看板で見かける文字ですが、今回は正しく読めているようです。かしこい!
それにしても「ふ」が「婦」を崩したものだと教えたわけでもなく、書き順や画数を教えたわけでもないのに、見事に学習してくれました。

なんとか満足のいく結果にたどり着いたので、今年のアドベントカレンダーへの連続投稿はこれで終了にします。

一足早いですが、、、

メリィ
クリスマス


kawakin1975さん、あとはよろしくお願いします!

2019年12月23日月曜日

くずし字を読む
(ニューラルネットワーク)

この記事は、ケーシーエスキャロット Advent Calendar 2019 の23日目の記事になります。

ニューラルネットワーク

いよいよ、機械学習です。と言っても機械学習で画像の分類課題を実現する方法には色々あります。例えば文字の画像が大量にあって、それらの画像を平仮名と片仮名に分類したいとします。機械学習で分類する場合のひとつの方法として次のようなやり方が考えられます。各画像の中に特徴点を見出し、その特徴を数値化してグラフ上にプロットしていきます。すると散布図ができますが、そのひとつひとつの点が平仮名なら青、片仮名なら赤で表しましょう。青の点が集まっている箇所と赤の点が集まっている場所がきれいにその散布図上に別れてくれれば話は簡単です。2つに分類するための境界線を数式で表すことができれば、それが人工知能の脳になります。新しい画像をその脳に通せば、平仮名か片仮名かをうまく分類してくれるでしょう。

ただ、このアプローチには大きな問題があります。プログラムを書いてみて実感したのですが『ツマラナイ』です。数学は嫌いじゃないのですが、それでも。。。理由は明白で、結局うまく分類するためのルールを考えるのは人間だからでしょう。数学の勉強としては良いてテーマですが『いやいや、機械に勉強してほしいのよ』という気持ちになります。

その点、やっぱり面白そうなのはニューラルネットワークです。人間の脳の神経細胞(ニューロン)とその連携の様子をコンピュータ上に再現することで、何故かうまく分類できちゃうという気持ち悪さが最高です。

では早速(ようやく?)、今回作成するニューラルネットワークの説明に入ります。


一番左が入力層、くずし字の画像データです。そして、右端の出力層では『あ』である確率が一番高いことを見抜いています。この区間でなにが起きているのかを3つに分けて解説します。

まず、入力層では 32 x 32 の二次元配列のデータを 1024 個の直列なデータに変換しています。これは、入力データが単に数値の羅列にすぎないと捉えて、より扱いやすいシンプルな形に変換しているだけです。

続いて中間層ですが、80 個のニューロンを用意することにしました。入力層からの矢印が一番上のニューロンに集中しているのは、入力層の全てのデータをひとつのニューロンにインプットしている様子を表しています。次の瞬間には2つ目のニューロンに対しても、入力層の全てのデータが入力されます。白い丸は、このニューロンが反応しなかったことを表現しています。反応したニューロンは赤で表現しています。ところで、各ニューロンは1024個のデータを同等には扱っていません。例えば、「1個目のデータに対しては最も小さい 0.1の重みをつけていて、54個目のデータには最も大きい2.7の重みをつけていた」と仮定しましょう。このニューロンは 1024 個のデータのうち54個目のデータをとても重要視していることになります。全入力データにそれぞれの重みをかけた値の総和を求めて、閾値を超えているかどうかで2値を出力します。各ニューロンには、この重み付けと閾値が個別に(初めはランダムに)決められています。そのため、全てのニューロンが同じ入力を受け付けているにも関わらず、結果が異なってくるのです。

なお、この「閾値で1/0(反応有無)の振り分けを行う処理」を活性化関数と呼びます。微分を使う都合上なだらかな曲線だけでできている関数にする必要があるので、シグモイド関数を使うことにします。他に使える関数は、keras のドキュメントに掲載されています。

最後に出力層です。中間層から出力された値は、出力層にとっての入力データになります。つまり、出力層のそれぞれのニューロンに対して、80 個のデータが等しく入力されることになります。そして、中間層のニューロンと同じように、個別の反応を見せるわけです。ひとつだけ違う点は、中間層の出力が2値だったのに対し、出力層は自分が正解である確率を出力する点です。「自分」と書いてしまいましたが、実は出力層のニューロンは 48 個にしてあって、これは 48 文字の平仮名の個数です。つまり1つ目のニューロンは "あ" 、2つ目は "い" …… というようにニューロンのひとつひとつが平仮名一文字に相当しています。確率を出力したいので、シグモイド関数を使うことはできません。この場合に相応しいのは、ソフトマックス関数です。これを使うと、48個のニューロンのそれぞれが正解である確率を全て足し合わせると 100% になるようにしてくれます。

全体を通して、入力データの何に着目してどう分類するかについては、人が関与していないのがポイントです。では、実際にプログラムを見てみましょう。
  1 #!/usr/local/bin/python
  2 # coding: utf-8
  3 
  4 from ImageLoader2 import LoadDataSet
  5 from ShowPrediction import ShowPrediction
  6 from keras.utils import np_utils
  7 from keras.models import Sequential
  8 from keras.layers import Dense, Activation
  9 from keras.optimizers import Adam
 10 import numpy as np
 11 import time
 12 
 13 datas = LoadDataSet()
 14 datas.get_data('save.pkl')
 15 
 16 num_classes = 48
 17 
 18 x_train = datas.train_img
 19 y_train = datas.train_label
 20 print(y_train.size)
 21 x_train = x_train.reshape(y_train.size, 1024)
 22 x_train = x_train.astype('float32')
 23 x_train = x_train / 255
 24 y_train = np_utils.to_categorical(y_train, num_classes)
 25 
 26 x_test = datas.test_img
 27 y_test = datas.test_label
 28 print(y_test.size)
 29 x_test = x_test.reshape(y_test.size, 1024)
 30 x_test = x_test.astype('float32')
 31 x_test = x_test / 255
 32 y_test = np_utils.to_categorical(y_test, num_classes)
 33 
 34 np.random.seed(1)
 35 
 36 model = Sequential()
 37 model.add(Dense(80, input_dim=1024, activation='sigmoid'))
 38 model.add(Dense(48, activation='softmax'))
 39 model.compile(loss='categorical_crossentropy',
 40               optimizer=Adam(), metrics=['accuracy'])
 41 startTime = time.time()
 42 history = model.fit(x_train, y_train, epochs=100, batch_size=100,
 43                     verbose=1, validation_data=(x_test, y_test))
 44 score = model.evaluate(x_test, y_test, verbose=0)
 45 print('Test loss:', score[0])
 46 print('Test accuracy:', score[1])
 47 print("Computation time:{0:.3f} sec".format(time.time() - startTime))

パッと見で、ひとつもネストしていないのが特徴です。これがすべてを物語っていますが、人が制御せず機械に丸投げしているので、結果的に for や if などの制御構文が使われない結果になっています。それでは、上記の絵の内容をどのように書いたら実現できるのか、順番に見ていきましょう。

11〜24行目では、Loader クラスを使って学習用のデータを取得しています。その時、ついでに 32 x 32 の二次元配列を 1024 の一次元配列に変換しています。入力層の処理はこれだけで、絵で示したとおりのことをしています。26〜32行目では、評価用のデータを同じように取得しています。

36行目では model を作っていますが、これが人工知能の脳にあたります。続く2行で中間層と出力層を追加していて、これもまた絵で示した通りになっています。もっと多層にしたければ add() を続ければ良いのですが、今回は2つにしておきます。

39行目で compile をしていますが、ここは絵では表現していなかったものです。実は、絵で説明したのは学習済みのニューラルネットワークの挙動です。というのも、学習していない状態のニューロンは適当な重み付けや閾値を持っているにすぎないので、"あ" というデータを入力したとしても、どのニューロンが一番確率が高くなるかはわかりません。一番最初はテキトーな結果になるでしょう。ここで必要になるのが学習用データの正解ラベルです。"あ" という画像データと一緒に、その画像が "あ" であることを示す情報も入力しています。たまたま出た結果が正解ラベルと一致しなかったときには、ニューロンの重み付けや閾値を微調整して正解に近づけるということを繰り返します(バックプロパゲーションと言います)。ニューロンが育っていくイメージが伝わるでしょうか。

このバックプロパゲーション、いつ止めるかが問題です。モデルの設計次第で過学習にも学習不足にもなりえるので、最適な手法を採用したいところです。学習の程度を示す目安に、損失関数という概念があります。全て正解した場合は0になり、間違いが多いほど大きくなる値なのですが、この値が 0 に近づくように学習を進めます。もし、学習時間が経過するとともに損失関数が徐々に0に近くなり、途中から過学習の状態になって数値が 上がっていくというなだらかな曲線ならば、微分を使って極小値を超えた時点でやめてしまえば良いのです。しかし、現実には損失関数のグラフはそんなに単純ではありません。一度極小値に達したら上がり続けるわけではなく、再び0に向かって下降していき、先ほどの極小値よりも0に近いところに達する可能性も充分にあるのです。そのように、局所最適解にはまり込まずに、全体最適解に辿り着くための方法が色々と考えられてきましたが、ここでは adam という最適化関数を使用します。

より詳しく知りたい方には、以下のページをお勧めします。

勾配降下法の最適化アルゴリズムを概観する
https://postd.cc/optimizing-gradient-descent/

42行目が、学習を丸投げしているところです。batch_size=100 というのは、入力データ数を 100 個ずつに分けてその batch 毎に学習を行うことを指示しています。全データ数を batch に分け、その全部の batch を1回ずつ学習するのを、1エポックと呼びます。epochs=100 というのは、エポックを 100 回繰り返すことを指示しています。

44行目は、評価を丸投げしているところです。42行目の学習で育ったニューロンに対して評価用のデータを入力して、損失や正解率をみています。

実行結果がちょっと見にくいですが、Epoch 100/100 と書かれたところに学習の成果が出力されています。
loss: 0.5358
acc: 0.8542
とありますので、損失が 0.53 とかなり大きめの数字です。もっと 0 に近づけたかったところですが、Epoch を増やしてもあまり良い結果にはなりませんでした。それでも正解率は 85% 程にはなっているので及第点くらいでしょうか。

そのすぐ下に評価結果も出力されています。
Test loss: 0.6641609928329454
Test accuracy: 0.8296347975340643
とありますので、損失が 0.66 でかなり大きいですね。正解率も 83% くらいにしかなっていません。学習用データに特化してしまった過学習の状況なのかもしれません。

数値的にちょっと残念な結果なのですが、それでも 80% 以上は正解しています。そこで、もっとビジュアルに結果が見られるようにしてみました。

評価に使われた画像のうち先頭の 96 枚について、実際の画像を表示しました。そして左下には青い文字で正解を、右下には赤い文字で人工知能が回答した結果を表示しました(こんなことも Python なら簡単にできるところがスゴイ!)。さらに不正解だったものは画像の上に赤い斜線を入れてみました。

一番上の段の左から2個目と、一番下の段の右端の画像は、どちらも「は」なのですが、きちんと読めているようです。対して、右上の「ふ」はちょっと難しかったようです。天ぷら屋さんの看板によく書かれている文字なので、これくらいは読めて欲しいところです。


ちょっと悔しい結果。次回、さらなる高みを目指す。

2019年12月17日火曜日

くずし字を読む (Loader クラスの作成)

この記事は、ケーシーエスキャロット Advent Calendar 2019 の17日目の記事になります。

Loaderクラスの作成

前回の作業で、データセットを用意するところまではできました。しかしこのデータセット、数万枚の画像ファイルでしかありません。これから学習のための Python コードを書くにあたって、試行錯誤するたびに、数万ファイルを開いたり閉じたりするのは非効率なので、少し工夫したいところです。そこで、一度数万ファイルを開いて読み込んだデータは、ひとつの pickle ファイルとして保存することにしました。pickle を使うと、python オブジェクトをシリアライズしてひとつのファイルとして保存したり、そのファイルを読み込んで python オブジェクトを復元したりすることができます。これを利用すれば、2回目以降のデータセットの利用は早くできそうです。

ところで、数万枚の画像ファイルを、どのような python オブジェクトとして表現すれば使いやすいのでしょうか? 機械学習で画像認識を行うために用意された、MNIST(Mixed National Institute of Standards and Technology database) という人気の高いデータセットがあります。ここでは、これにあやかって(パクってじゃないですよ) Loader クラスを作ります。MNIST と同じようなデータセットとして KuzushijiDataSet というクラスを作ったとしたら、以下のような使い方になります。

  1 kds = KuzushijiDataSet()
  2 kds.load_data('save.pkl')
  3 
  4 x_train = kds.train_img    # (32 * 32) の画像リスト
  5 y_train = kds.train_label  # 上記の正解値 (‘あ’== 0 )
  6 x_test = kds.test_img
  7 y_test = kds.test_label
  8

こういう Loader クラスがあれば、x_train(学習用のデータリスト) と y_train(学習用の正解リスト) を使って学習させるプログラムも書きやすいですし、学習が済んだ人工知能に対して x_test(評価用のデータリスト) と y_test(評価用の正解リスト) を使って学習の成果をチェックするのも簡単になります。
作成した Loader クラスの解説に先だって、ソースコードを貼り付けておきます。

  1 #!/usr/local/bin/python
  2 # coding: utf-8
  3 
  4 import os
  5 import pickle
  6 import numpy as np
  7 from PIL import Image
  8 
  9 class KuzushijiDataSet:
 10 
 11     def __init__(self):
 12         """初期化処理"""
 13 
 14         self.train_img = None
 15         self.train_label = None
 16         self.test_img = None
 17         self.test_label = None
 18 
 19     def load_data(self, save_file):
 20         """既に作成したpickleファイルからデータを読み込む(なければ作成する)"""
 21 
 22         if not os.path.exists(save_file):
 23             self.create_pickle(save_file)
 24 
 25         with open(save_file, 'rb') as f:
 26             dataset = pickle.load(f)
 27 
 28         self.train_img = dataset['train_img']
 29         self.train_label = dataset['train_label']
 30         self.test_img = dataset['test_img']
 31         self.test_label = dataset['test_label']
 32 
 33     def create_pickle(self, save_file):
 34         """画像・ラベルを読み込み、pickleとして作成・保存する"""
 35 
 36         print('create_pickel')
 37         train_img, train_label, test_img, test_label = [], [], [], []
 38 
 39         # traningディレクトリはの画像はtrain_img, train_labelへ
 40         # testingディレクトリはtest_img, test_labelへ
 41         kinds = [
 42             ['training', train_img, train_label],
 43             ['testing', test_img, test_label],
 44         ]
 45
 46         for kind in kinds:
 47             # この i はディレクトリ名であると同時に、正解ラベル名
 48             for i in range(1, 48):
 49 
 50                 # output/training/0 等のパス
 51                 dir_path = os.path.join('output', kind[0], str(i+1))
 52 
 53                 # [output/training/1/1.png, output/training/1/2.png]等のリスト
 54                 file_paths = [os.path.join(dir_path, file) for file in os.listdir(dir_path)]
 55 
 56                 # [[画像のnumpy配列], [画像のnumpy配列], ...])のような2次元配列
 57                 images = [np.asarray(Image.open(path)).reshape(32, 32, 1) for path in file_paths]
 58                 kind[1].extend(images)
 59 
 60                 # ディレクトリ名のiが、そのまま正解ラベル名なので、1を入れる
 61                 label = [0] * 48
 62                 label[i] = 1
 63                 labels = [label for x in file_paths]
 64                 kind[2].extend(labels)
 65 
 66         dataset = {
 67             'train_img': np.array(train_img),  # (n_train, 32, 32)
 68             'train_label': np.array(train_label),  # (n_train, 48)
 69             'test_img': np.array(test_img),  # (n_test, 32, 32)
 70             'test_label': np.array(test_label),  # (n_test, 48)
 71         }
 72 
 73         with open(save_file, 'wb') as f:
 74             pickle.dump(dataset, f, -1)
 75 

9行目でクラスの宣言。インスタンス化されたタイミングで 11〜17行目の __init__() が呼ばれますので、メンバ変数を初期化しています。train_img は学習用画像データで、画像ファイル数分のリストです。train_label も同じく学習用で、正解ラベルのリストです。この正解ラベルは、ちょっとおかしな構造になりますので後で具体的に説明します。

19〜31行目はデータセットを Loader クラス内にロードする処理です。引数の save_file には pickle のファイル名が渡ってくる想定です。このファイルが存在していれば読み込んでメンバ変数に展開するだけです。もし、存在していなければ、画像ファイルをひとつひとつ読んで pickle ファイルとして保存するために create_pickle() を呼ぶ処理を挟みます。

33〜75行目が create_pickle() です。"0" という名前のフォルダ(="あ")から "48" という名前のフォルダ (="ん") までを順番に開いて、中の画像ファイルを全て読み込んでいきます。ポイントは、2重の for 文の中に書かれている 57〜64行目の処理です。読み込んだ画像イメージを numpy のリストとして train_img に保存します。こちらは理解しやすいと思います。分かりにくいのは、もうひとつの train_label の方です。例えば train_img の1つめの要素が「あ」の画像データだとします。その場合、train_label の1つめの要素として "あ" を表す 0 などの数値を保存しておけば良さそうなものですが、実際には "あ" から "ん" までの48個分の数値リストを用意して、そのうちの 0 番目(== "あ") の要素だけを 1 にしておきます。

って、分かりにくいですね。。。

train_img の2つ目が 「え」 の画像データだったら、train_label の2つ目は (0, 0, 0, 1, 0, 0,,,,0) となります。データ量としては無駄なのですが、計算が早くなるメリットがあるのでこの構成にしています。
同じように test_img は評価用の画像データのリストで、test_label は評価用の正解ラベルです。

最後の 73〜74 行目で、pickle ファイルとして保存しています。



次回はいよいよ 「実践編」。機械学習を使って、画像ファイルの解析をしてみます。

2019年12月10日火曜日

くずし字を読む (準備編)

この記事は、ケーシーエスキャロット Advent Calendar 2019 の10日目の記事になります。


前回の記事でちょっとミスりました。”これら、すべて「き」と読みます” なんて書いておきながら、ひとつも正しく表示されていないというお粗末な結果に。。。

U NICODE には登録されている文字なのですが、それに対応したフォントファイルをインストールした環境でないと正しく表示されないのでした。 「IPAmj明朝」フォントをすでにインストールしていたなんていうマニアックな人以外は見られなかったと思います。というわけで、部分的に画像ファイルに差し替えておきましたので、今はどのブラウザでも表示できるはずです。

データセットの準備

機械学習のうち、今回扱う手法は「教師あり学習」です。なので、自分が教師役として PC に学習させるというスタンスで話を進めます。まずは学習させるためのコンテンツが必要です。今回はくずし字を読めるようになってもらいたいので、大量のくずし字画像を手に入れます。その後、PC に画像を読み込ませる時に正解も一緒に教えます。つまり「あ」の画像を読み込ませながら、それは「あ」と読むんだよ、と教えてあげるわけです。ここまでが『学習』の過程です。学習が終わったら、まだ読み込ませたことのない画像ファイルを読ませたみて、正しく分類できるかどうかを確認します。

とにかく、まずは大量のデータが必要です。古文書を開いてデジカメで撮影し、1文字ずつ画像ファイルにしていきましょう!
っていう気の遠くなるような作業はとてもやっていられませんので、そんな作業をやってくれている人を探してみると、、、あるんですねぇ、そういう機関が。

人文学オープンデータ共同利用センター
http://codh.rois.ac.jp
このデータセット(国文学研究資料館ほか所蔵/情報・システム研究機構 データサイエンス共同利用基盤施設 人文学オープンデータ共同利用センター加工)は、クリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンス(CC BY-SA)の下に提供されています。

例えば、
【南総里見八犬伝】
http://codh.rois.ac.jp/char-shape/book/200014685/

からデータセットをダウンロードすると、15,864文字分もの画像データが入手できます。ありがたい!

私は15冊分のデータを使わせてもらいました。本のタイトルをフォルダ名にして、それぞれのフォルダの中に「あ」から「ん」までの名前をつけたフォルダを掘り、その中に画像データが置いてあるという構成にしました。(漢字も含めると大変な量になるので、自分に優しく、今回はひらがなだけに絞ることにして、漢字の画像ファイルは捨てました)



と、ここで、いくつか不都合な点に気がつきました。
  1. ファイルの大きさがバラバラ。
  2. ファイルの縦横比がバラバラ。
  3. 裏移りしている文字がある。
  4. 薄過ぎる文字がある。
  5. 墨で潰れてしまっている文字がある。
  6. 欠けている文字がある。
  7. ひとつのファイルに他の文字が含まれてしまっているものがある。

本来ならこんな違いはものともせずに学習してほしいところですが、生徒に優しい私としては、もう少しデータセットをきれいにしておきたいところです。上記の何が不都合なのかを簡単にまとめておきます。

  1. ファイルの大きさがバラバラ。
  2. ファイルの縦横比がバラバラ。



文字を理解するのに必要な特徴だけ含まれていれば画像としては充分ですので、必要以上に大きくてもメリットはありません。容量が嵩張るだけ無駄ですし、学習時にも時間がかかってしまいます。縦横比が異なっていても本来は問題ないのですが、あとでとある技法を使いたいので、全て同じ縦横比になっていると都合が良いです。なので、これらのデータは全てサイズを統一させておくことにしました。


  1. 裏移りしている文字がある。
  2. 薄過ぎる文字がある。
  3. 墨で潰れてしまっている文字がある。
  4. 欠けている文字がある。



これらは、単純に学習精度が落ちる気がするので、学習データに含めたくないと感じたものです。これらのデータは削除することにしました。本当ならそういう画像でも正しく読めて欲しいところですが、今回は優しく、ということで。

  1. ひとつのファイルに他の文字が含まれてしまっているものがある。



これは、「し」を長方形に切り出したものですが、どうみても「すべし」です。自由なレイアウトで書かれているので、こんな画像ファイルもできてしまいます。今回はこれも削除することにしました。


データセットの補正

上記の問題点を踏まえて、まず削除対象のファイルを手でコツコツと削除しました。(これが相当にしんどかった。。。) 残った画像ファイルは、平仮名の48種類、225,417枚です。この大量の画像ファイルが、本のタイトルごとにフォルダに入っている状態です。

これらの画像ファイルに施したい補正は、32x32 のサイズに統一することです(ついでにカラーの情報も要らないのでグレースケール化したいところです)。アルゴリズムとしては、画像ファイルを1枚ずつ読み込み、32x32 のサイズに統一しつつグレースケール化し、新しいファイルとして保存していきます。保存先は、学習用と評価用のフォルダを用意し、5対1の割合でランダムに振り分けていきます

 解説に先立って、まずはソースコードを貼ってしまいます。
  1 #!/usr/local/bin/python
  2 # coding: utf-8
  3 
  4 import os
  5 import random
  6 
  7 import numpy as np
  8 from PIL import Image
  9 from PIL import ImageStat
 10 
 11 classes = {'あ': 0, 'い': 1, 'う': 2, 'え': 3, 'お': 4,
 12            'か': 5, 'き': 6, 'く': 7, 'け': 8, 'こ': 9,
 13            'さ':10, 'し':11, 'す':12, 'せ':13, 'そ':14,
 14            'た':15, 'ち':16, 'つ':17, 'て':18, 'と':19,
 15            'な':20, 'に':21, 'ぬ':22, 'ね':23, 'の':24,
 16            'は':25, 'ひ':26, 'ふ':27, 'へ':28, 'ほ':29,
 17            'ま':30, 'み':31, 'む':32, 'め':33, 'も':34,
 18            'や':35,          'ゆ':36,          'よ':37,
 19            'ら':38, 'り':39, 'る':40, 'れ':41, 'ろ':42,
 20            'わ':43, 'ゐ':44,          'ゑ':45, 'を':46,
 21            'ん':47}
 22 
 23 size = width, height = 32, 32
 24 RootPath = u'./dataset'
 25 OutTestingPath = u'./output/testing'
 26 OutTrainingPath = u'./output/training'
 27 TopList = os.listdir(RootPath)
 28 
 29 random.seed(3284)
 30 
 31 for subdir in TopList:
 32     if subdir == u'.DS_Store':
 33         continue
 34     print(subdir)
 35 
 36     SubDirPath = os.path.join(RootPath, subdir)
 37     SubDirList = os.listdir(SubDirPath)
 38     for dir in SubDirList:
 39         if dir == u'.DS_Store':
 40             continue
 41
 42         EndDirPath = os.path.join(SubDirPath, dir)
 43         FileList = os.listdir(EndDirPath)
 44         OutTestingDirPath = os.path.join(OutTestingPath, str(classes.get(dir)))
 45         OutTrainingDirPath = os.path.join(OutTrainingPath, str(classes.get(dir)))
 46         if not os.path.exists(OutTestingDirPath):
 47             os.makedirs(OutTestingDirPath)
 48         if not os.path.exists(OutTrainingDirPath):
 49             os.makedirs(OutTrainingDirPath)
 50 
 51         for fname in FileList:
 52             
 53             # 画像を一枚開く (この時点ではサイズはバラバラ)
 54             img = Image.open(EndDirPath + '/' + fname).convert('L')
 55 
 56             # 32 x 32 に収まるように縮小 (縦横比は元のまま)
 57             img.thumbnail(size, resample=Image.ANTIALIAS)
 58             
 59             # 以下で、32 x 32 の周囲にできる余白部分をなじませる
 60 
 61             # 左上と右上のピクセルのうち明るい方の色を背景色として採用して、
 62             # 新しい Image オブジェクトを作成
 63             img_width, img_height = img.size
 64             bk_color = max(img.getpixel((img_width-1,0)), img.getpixel((0, 0)));
 65             bg = Image.new("L", size, bk_color);
 66             
 67             # 元の画像を中央に貼り付ける 
 68             center = (width - img_width) // 2, (height - img_height) // 2
 69             bg.paste(img, center)
 70             
 71             # 学習用のデータと評価用のデータに振り分ける
 72             r = random.randint(1,6) 
 73             if (r == 1):
 74                 bg.save(OutTestingDirPath + '/' + fname)
 75             else:
 76                 bg.save(OutTrainingDirPath + '/' + fname)
 77        

全ての画像ファイルを読み込むために、for 文でグルグルしているところは割愛して、57 行目がポイントです。 縦横比を変えないまま 32x32 に収まるようにサイズを変換しています(1行でできちゃう、さすが Python!)。

ただし、ここでひとつ問題が発生します。例えば縦長の「し」という文字を、背景が白い 32x32 の中に収めた場合、左右に白い長方形の領域が残ってしまいます。「フランス国旗のように3つの領域があって、真ん中が黄ばんだ感じ」という余計な情報も含めて「し」だと学習されるとちょっと困ります。できれば「し」という文字が書かれている紙の黄ばんだ感じの色で、32x32の領域を埋めておきたいところです。とはいうものの、自然に黄ばんだものですし、本によってその程度もまちまちですので、固定値にすることもできず困ったものです。

その辺りの工夫が、63〜69行目です。コメントに書いてある通りですが、結構アナログな発想でなんとかしました。実際に変換したファイルは以下のようになります。


上記は「あいうえお」の5文字です。なんとか、きれいに変換できました。

最後の 72 行目以降で、変換後の画像ファイルを「学習用」と「評価用」に分けています。



次回は「Loaderクラスの作成」。まだ、学習は始まらない。。。

2019年12月3日火曜日

くずし字を読む (概要編)

この記事は、ケーシーエスキャロット Advent Calendar 2019 の3日目の記事になります。

くずし字とは

今回のテーマは「くずし字」。今でもたまに見かける、ちょっと古い文字ですが、それを機械学習を活用して読み分けてみようと思います。例えば下の看板を人工知能は読めるのでしょうか?。



上記の看板は、浅草にある老舗のお蕎麦屋さんのものだそうです。「やぶそば」と書いてあります。この程度なら機械学習の出番ではありませんが、日本の各地に眠っている古文書を読むとしたら、機械学習はきっとその威力を発揮するはずです。

「もう、そんな OCR があるんじゃないの?」と思われるかもしれませんが、実は手書きのくずし字を読めるような OCR はまだ実用レベルにはなっていません。印刷された活字の日本語を読める OCR はありますが、くずし字は読めないのです。その理由はいくつかあります。
  1. 文字の境目が分かりにくい。
  2. 1つの音を表す文字がいくつもある。
  3. 2つの文字が連結しているケースがある。
  4. 自由なレイアウトで書かれている。
下の百人一首の札を参照しながら、ひとつひとつ見ていきましょう。


1. 文字の境目が分かりにくい

読めないのは仕方ないとしても「何文字あるか」さえわからないくらいです。ひとつの文字を認識する以前に、どれがひとつの文字なのかを認識するのが難しいのです。
まぁ、百人一首の「上の句」だと気づけば、五・七・五の22文字だと分かりますけどね。


2. 1つの音を表す文字がいくつもある


これら、すべて「き」と読みます。ちなみに、左から3文字目の「 」をさらに簡略化すると「」になっていきます。さらには、ここに挙げた9文字は、UNICODE に登録されたものだけです。他にもあるのかもしれません。。。


3. 2つの文字が連結しているケースがある

。これだけで「より」と読みます。2文字が連結して1文字のように表現されているのですが、こういった文字を「合略仮名」と呼びます。「よ」と「り」が連結している様子がわかるでしょうか?
他にも「こ」の2画目と「と」の1画目が共有されている「こと」という合略仮名も古文書にはよく出てきます。(残念ながら「こと」の方は、まだ UNICODE に登録されていないようです)


4. 自由なレイアウトで書かれている

もう一度、百人一首の札を見てください。一番左の1行は「さむしろに」という5文字です。「む」と「し」の位置関係が現代の感覚ではかなり不自然ですが、実際の古文書にはよく見られます。



他にも、時代や地域によって崩し方に違いが見られたり、当然、人によっても大きな違いが見られます。色々と難しい問題はありますが、今回は「1文字分の画像を入力したら、現代の50音の平仮名のどれに該当するかを答える」というところに焦点を当てて、ディープラーニングを使ってみます。
次回は「準備編」。データセットを作るまでの悪戦苦闘をお送りします。