2024年12月4日水曜日

趣味とスキルマッピング(スキルの充実化)

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

前回の記事の最後に「ちょっと飽きてきたなぁという怠惰な思いと、スキル増やしたいなぁという好奇心、どっちが勝つんでしょう?」なんて書きましたが、まぁ、好奇心が勝つわけですよ、マジシャンなんでね。


そんなわけで今回はスキルの方を充実させてみます。山ほどあります。そして分類方法も色々考えられます。


スキルをカテゴライズ

アメリカの経営学者ロバート・L・カッツが提唱した、いわゆるカッツ理論が良さそうです。テクニカルスキル・ヒューマンスキル・コンセプチュアルスキルの3つを定義して、マネジメントのレベルが上がるにつれて、よりコンセプチュアルスキル側の能力が必要になってくるという考え方です。単純に階層化するのではなく、全て必要だけどその割合が変わっていくという考え方は的を射ていると思います。無数にあるビジネススキルをこの3つのカテゴリに分けてみることにしました。

ただ、ビジネススキルとしては良い感じですが、もっと根本的なスキルが抜け落ちてしまいます。EQ(非認知能力) と呼ばれるスキルですが、これは他のスキルと違い、数値化できない能力であると説明されることが多いです。とはいえ、今回のアプリではスキル自体を数値化するわけではなく「そのスキルを育むのにどの程度影響力があるか」という観点ですので、数値化は可能です。一応 ChatGPT にもその観点でなら数値化できるかどうか聞いてみました。「もちろんできます!」という前向きなコメントと共に具体的なスキルや数値をたくさん例示してくれました。あまりいっぱい返ってくるので「このアプローチは人類にはまだ早い?」って聞いてみたら「たしかに、人類にはまだ早いかもしれませんね(笑)」とか言ってくるので、数値化する作業は全部任せることにしました。

自分で数値化するのは大変なのでスキルの数を増やすのを躊躇していましたが、やってくれると言うので遠慮なくスキルを増やします。「テクニカルスキル」「ヒューマンスキル」「コンセプチュアルスキル」「EQ(非認知能力)」の4つのカテゴリに対して、それぞれ8つのスキルを定義して、合計32個のスキルを数値化することにしました。

ついでに、、、

32個のデータのレーダーチャートとか見ていられないので、4つのカテゴリごとに横棒グラフで表現することにしました。

サンプルアプリ

今回も Google スプレッドシートの Apps Script で作った UI を埋め込めこんであります。触ってみてください。

※ 複数の Google アカウントがブラウザにログインしている状態だと正しく表示されないようです。表示されない場合は、シークレットモードで試してみてください。



色々見比べてみても、やっぱりマジックはなかなかポテンシャルが高いようです。本気で遊ぶだけでスキルが後からついてくる。お得な趣味を持ったものです。

2024年12月3日火曜日

趣味とスキルマッピング(趣味の充実化)

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

前回の [趣味とスキルマッピング] では、趣味とビジネススキルをマッピングさせるアイディアを形にしてみました。自分の趣味に合わせて「マジック」と「将棋」と「バスケ」を取り上げてみましたが、これでは使える人が限られるので、もう少し趣味の幅を広げてみることにします。とはいえ趣味って無数にあるので。。。


趣味をカテゴライズ

「将棋」については、囲碁やオセロなども同じような効果を持っていそうなので「ボードゲーム」としてまとめて扱うことにしました。同じように「バスケ」もサッカーやラグビーなどと一緒にして「スポーツ」とできそうですが、「チームスポーツ」と「個人スポーツ」では、対応するスキルに差がありそうなので分けた方が良さそうです。続きは Chat GPT に任せて 20 個定義してもらいました。(良い時代になったなぁ...)

マジック映画手芸カメラ
ボードゲーム釣り書籍
チームスポーツ音楽温泉・サウナキャンプ
個人スポーツ絵画ガーデニング漫画・アニメ
料理旅行嗜好品デジタルゲーム

なんだか気になるところもありますが、実験としては充分なのでこれでやってみます。

ついでに...

Chart.js で描いたレーダーチャート。前回はデフォルト表示のままでしたが、今回は Blogger のデザインに馴染ませてみます。これをやったからといって何か良いことがあるわけではありません。ただの気晴らしです(最近、飽きっぽいので)。

サンプルアプリ

で、前回同様 Google スプレッドシートの Apps Script で作った UI を埋め込みました。

※ 複数の Google アカウントがブラウザにログインしている状態だと正しく表示されないようです。表示されない場合は、シークレットモードで試してみてください。



趣味の数を増やしたらスキルの方も増やしたくなりますね。ビジネススキルをカッツのモデルを意識してカテゴライズした方が良いのかなぁとか、EQ (非認知能力) の方が趣味との親和性が高いんじゃないかなぁとか、気になってきました。

ちょっと飽きてきたなぁという怠惰な思いと、スキル増やしたいなぁという好奇心、どっちが勝つんでしょう?

2024年12月2日月曜日

趣味とスキルマッピング

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

久しぶりのアウトプット。今回は趣味と仕事の関係について考えてみます。後輩と趣味の話になった時に「それって、きっと仕事にも役立つだろうね」という流れになり、ふと思いました。「趣味によって、ビジネススキルを育む力が違うのでは?」と。私の趣味はマジックですが、振り返ってみると、問題解決力・論理的思考・戦略的思考・プレゼンテーションなどといったスキルは、マジックに打ち込むことで底上げされていたのかもしれません。バスケに打ち込んでいた時には、リーダーシップや集中力・忍耐力なども鍛えられたかもしれません。


そこで、「趣味とビジネススキルをマッピングしてみるのってどう思う?」って ChatGPT に聞いてみたら「面白いアイディアですね!」と返ってきました。こういう時は、プロンプトエンジニアリングなんか忘れて、あえてダラダラと会話を続けるのがオススメです。会話を続けながら簡単なアプリを作成したので、最後に紹介します。イメージとしては、趣味の一覧から「バスケ」と「ピアノ」を選択すると、その趣味によってどのビジネススキルがどれだけ上がる可能性があるのか、そのポテンシャルをレーダーチャートで表示するというものです。

趣味も色々

まずは「趣味」をどう定義したものか。。。趣味自体がたくさんあるのはもちろんですが、同じマジックという趣味でも、パフォーマーと研究家では育つスキルも大きく変わってきます。そこで「趣味への関わり方」というものを定義することにしました。どんな趣味にも共通して存在するような関わり方を MECE に定義するのはちょっと難しいですが、そこは ChatGPT の力を借りてチャチャっと定義します。

結果、以下の8項目になりました。ほとんどの趣味がこの8つの関わり方に分類できそうです。

関わり方具体的な行動例
実演演じる。プレーする。披露する。
研究知識・技術を探究する。理論や戦術を学ぶ。
観賞観戦する。観賞する。受け身で楽しむ。
収集関連グッズやアイテムをコレクションする。
交流同じ趣味を持つ人と交流する。情報交換する。
創作新しいものを作る。
支援イベントの運営をする。実演者や鑑賞者を支援する。
指導後進への指導や育成を行う。

「趣味」と「関わり方」の2次元でチェックボックスを並べれば、良い感じに選択できそうです。入力はこれで OK とします。続いて、出力側を考えます。

スキルも色々

ビジネススキルといっても山ほどあります。たくさんあるだろうとは思っていましたが、調べてみるとホントにキリがないくらいありました。こんな時は最近身につけた新しいスキル「テキトー」を発揮します。「テキトー」を使うことで、無数にあるスキルを8つに絞り込むことができました。たまたま8つに落ち着いたので、レーダーチャートでバランス良く表示することもできそうです。

サンプルアプリ

あとはプログラムを書くだけですが、せっかくならこのサイト上で動かせるようなものにしたいと思います。Google Blogger 上でなんらかの Web App を動かすなら Google のサービスを使うのが良さそうです。Google スプレッドシート上に、「趣味」と「関わり方」と「スキル」のスコアマップを用意し、拡張機能の Apps Script で UI アプリを作ります。できあがったらデプロイして、そのアプリの URL を Blogger 上に貼るだけです。具体的な手順やコードの説明をすると話が長くなりますが、ここで先週身につけた新しいスキル「適度に手を抜く」を発揮します。これにより説明をマルっと割愛できます。そんなわけで、出来上がったアプリを公開します。

※ 複数の Google アカウントがブラウザにログインしている状態だと正しく表示されないようです。表示されない場合は、シークレットモードで試してみてください。



趣味やビジネススキルの数を増やせばもっと使えるアプリになのではないかと思うのですが、どうでしょう?

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


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