2025年12月11日木曜日

心理的安全性の分析
トリプルシンキング

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

これまでの3つのストーリー、まとめるとこうなります。

1章:ググール国、侵攻を深める
2章:まほろば国、親交を深める
3章:あしはら国、信仰を深める

色々な「シンコウ」の深め方があるものですが、狙いと違う結果になっているなら残念なことです。

世の中にあふれる「心理的安全性」についての記事には、まほろば国へと誘導するような間違った内容のものが結構あります。
幸いなことにそれに警鐘を鳴らす記事も多いのですが、残念ながらあしはら国へ誘導しているように見えるものも多いです。

やらないよりは良かったね、と言えなくもないのです。実際「メンバ間の交流が増えて良かったです」という報告をもって、成功事例として紹介している記事もたくさんあります。

ただ、個人的にはモヤモヤして落ち着きません。だって目的が違うから。そこでこのモヤモヤを年内に解決するべく、トリプルシンキングしてみました。

あらためて google の研究結果のリンクを貼っておきます。
「効果的なチームとは何か」を知る


■ まずは気楽に、ラテラルシンキング


google の研究内容を読んでいると「メンバーは皆話したがっている」とか「メンバーにはそれぞれにやりたいことがある」ということが前提になっているように感じます。例えるなら皆がアクセルを踏んでいる状況です。アクセルは踏んでいるものの、心理的安全が低いためにブレーキもかかっているような組織を想定しているのではないでしょうか。だからこそ、心理的安全性を高めてブレーキを解除することで自然に前進するという論理展開です。メンバーが全員アクセルを踏んでいる、もしくは踏もうとしている前提なら確かに効果がありそうです。

でも、実際にそんな組織がどれくらいあるでしょうか。自分の名前を上げたい、出世したい、活躍したい、そんな熱い思いを持ったメンバが集まっている組織よりも、自分の役割を誠実にこなすというスタンスの人が集まっている組織の方が馴染みがあるのではないでしょうか?文化的な背景の差があるので、個人的主張を持って会議に参加するよりも、与えられた役割の範囲で順番に状況を報告するという経験の方が多いと思うのです。

うまく表現するのが難しいのですが、文化的背景が違えば、リスクとして感じるポイントもちょっと違うのではないかと思います。来週から来なくて良いよと言われたり、プロジェクトから急に外されたり、発言に対してはっきりとした批判を受けたりするならリスクです。その危険を冒してまで発言をするのはハードルが高くなります。だから心理的安全性を高めようと言うのは分かりますが、日本でそんなリスクに晒されている人はあまりいないはずです。日本人が感じているリスクは違うところにあるような気がします。

あえて言うなら、goole が想定していると思われる前述のようなリスクは「恐れ」で、日本人が感じているのは「畏れ」ではないかと。「先輩を差し置いて経験不足の自分がアイディアを出すのは躊躇われる」とか「最低でもこれくらいのチェックはしておかないと不安だな」とか「このペースでは他チームに迷惑がかかるんじゃないか」とか、そういう配慮からくる「畏れ」の方が日常的に意識されるのではないでしょうか?

google が想定しているのは、みんなでアクセルを踏んでいるものの、前方にある明確な「恐れ」のためにブレーキがかかり 5km/h しか出ない組織。
それに対して日本の場合は、背後から迫る暗黙の了解のような不明瞭な「畏れ」に押されて、慎重に 5km/h で進み続けている組織。
こんな傾向の違いを感じます。どちらも現在 5km/h で進んでいるのは同じです。もっと早く進めたいという気持ちも同じでしょう。でも「恐れることはないよ」を言えばブレーキが外れて加速するのに対し、「畏れることはないよ」と言えば推進力が失われて進む意味がなくなります。

もし、自分が所属している組織が google 社内のチームとそっくりだと感じるならラッキーです。google の研究結果を参考に心理的安全性の効果を味わいましょう。でももしそうじゃないなら、心理的安全性を取り入れるのは注意が必要です。

【使用上の注意】
心理的安全性の有効性は確認されていますが、体質によっては効果が出ない場合があります。

■ ちょっと真面目に、ロジカルシンキング


「心理的安全性とは何か?」について語られることが多いですが、それよりも「効果的なチームを作るにはどうするか?」という視点で分析したいと思います。google もそもそもそういう目線で研究していますので。

諸々はざっくりと省略して、結論を式で表すと次のような感じに整理できるのではないでしょうか。
「効果的なチーム」= 「実践的積極性」x「心理的安全性」

「実践的積極性」という見慣れない言葉が出てきました。それもそのはず、私の造語です。これは先ほどの「メンバーが全員アクセルを踏んでいる、もしくは踏もうとしている」という状況にも名前をつけてみた結果です。google のような組織では、この前提が当たり前すぎて研究対象にならなかったのではないでしょうか。でも大事な条件だと思うのです。実践的積極性がなければ、いくら心理的安全性を高めても、効果的なチームにはなり得ないでしょう。

ということは、組織をよく見て「実践的積極性」と「心理的安全性」のどちらをどの程度高めたいかを設計するのが肝要ということになります。この視点で解説されている記事を読んだことがありませんので、もしかすると斬新な発見なのかもしれません。(すっとこどっこいなだけかもしれませんけど)

日本では「実践的積極性」が見られないケースも多いと思います。これは能力が足りないのではなく、文化の違いです。自分の役割を誠実にこなすことを美とする感覚で成果を上げられちゃうのが日本のすごいところです。これを google の言う「効果的なチーム」にしたいなら「実践的積極性」を見につけた上で「心理的安全性」を高めるというアプローチになるのではないでしょうか。おそらく「誠実に確実に役割をこなす」という組織のままで「心理的安全性」を高めると「そこまで頑張らなくても良いよ」という誤ったメッセージになりやすいと思います。

【使用上の注意】
一歩踏み出す気力が湧かない。そんな症状には「実践的積極性」の服用をお勧めします。

少し本気の、クリティカルシンキング


クリティカルに考えると、ついダメ出しのような形になってしまいますので、ちょっと趣向を変えて「錯覚」という視点で整理します。

▪️錯覚1:心理的安全性が高まると雰囲気が良くなる
これが良くある誤解 NO.1 じゃないでしょうか。実際には安心してリスクのある発言ができるようになるわけですから、発言しない時よりもストレスは増えます。誤解を恐れずに言うなら、安心して口喧嘩できるような強い組織を作るイメージの方が近いと思います。

▪️錯覚2:心理的安全性はどの組織にも有効
google がはっきり書いているように「チーム」に対してだけ有効です。「ワークグループ」への有効性は言及されていません。
さらに、これは見落とされがちですが「google 社内のチーム」に対しての研究結果です。他社のチームのことには言及していません。
すこし前に書いたように「実践的積極性のあるチーム」であれば効果があるのではないかと(私は)思います。

▪️錯覚3:心理的安全性を高めれば行動が増える
心理的安全性とは関係なく行動力は必要です。その力を阻害している何かがあるなら取り除いてあげるために心理的安全性を高めることは有効です。心理的安全性自体に推進力はありません。

▪️錯覚4:心理的安全性はリーダーが高める
リーダーができるところ、リーダーにしかできないところは確かにあります。しかし、実際には組織の中に心理的安全性を脅かす存在が一人でもいれば無に帰します。どのような立場で組織に参加していても、心理的安全性を損なうような言動は慎みましょう。逆に、どのような立場でも心理的安全性を高めることに一役買うことができるという意識で参加することも大切です。

▪️錯覚5:心理的安全性はどんな目的にも有効
あくまでも「効果的なチーム」を作ることに有効なだけです。とても良い効果が出てイノベーションを起こせるかもしれませんが、残業は増えるかもしれません。そう考えると、たとえば「働き方改革」を意識して残業を減らしたいという目的があるなら、心理的安全性を高めることとは相性が悪い可能性すらあります。

【使用上の注意】
服用に当たっては事前に注意事項をよくお読みください。
既に服用しているものと飲み合わせが悪いこともあります。

まとめ

心理的安全性は、用法容量を守って正しくお使いください。

2025年12月10日水曜日

心理的安全性の物語
第3章:あしはら国の戦い

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

「心理的安全性」を導入するなら自組織に合わせてカスタマイズしよう!という方向での導入事例です。


第3章:あしはら国の戦い

あしはら国の大将は、机の上に広げられた一巻の巻物を、しばらく黙って眺めていた。
表紙には、力強い筆致でこう記されている。

『心理的安全性 正しき心得』

数年前、ググール国より伝わったというその思想は、すでにあしはら国のあちこちで語られていた。大将自らも目を通し、何度も読み返している。ググール国の戦士たちは盾を持つことで戦う力を得たと聞くが、彼らを強くした本質は、盾そのものではないということも見切っていた。

「なるほど……これは“ぬるさ”の話ではない。“正しく扱えば、刃になる思想”なのだな」

大将は深くうなずき、重臣たちを集めた。

軍議の場。重臣たちは巻物の内容を再確認し、その極意を確認しあった。巻物にはこう記されていた。
一、誤りを恐れず語れ
一、異なる意見を敵とみなすな
一、場の安全は全員で守るものなり

「なるほど……」
「これは深い」
「実に、理にかなっておりますな」

そして、しばらくして──
活発な議論とともに、次々と新たな巻物が作られていった。

最初に生まれたのは、続編だった。

『続・心理的安全性 正しき心得』
原著の内容をあしはら国に合わせて書き直したものであり、「あしはら版」を冠しても良さそうなものだが、原著への敬意を込めて続編という扱いにしたらしい。これだけの内容を一巻にまとめられたことは、重臣たちの知識が洗練されてきた証である。それに大将は安堵していたのだが、なんと重臣たちはさらに先を見据えていた。

「心得だけでは足りぬ」
「現場で迷う者が出るのではないか」
「補足が必要か」
などと、さらなる探究心を見せ始めたのだ。

そしてまもなく、新たな巻物が棚に並んだ。
『安心して語らうための十箇条』
『否定なき議論の作法』

それでも重臣たちの探究心は止まらない。
『一対一 対話之儀』
『対話を生む拝聴の極意』

次々と棚に増えていく巻物。さらには参照するべき巻物を即座に見つけられるように、索引だけの巻物も用意された。

もはやググール国の戦術書を超えていると言っても過言ではない。棚を埋め尽くす巻物に検索用の巻物まで添えられ、道ここに極まったかに見えたが、重臣たちはそれでも止まらなかった。

何故この手法が効果を生むのか、人の心に何がどのように作用するのか、その肝を詳らかにせずして真の「心理的安全性」は語れぬと息巻き、ついには『心理的安全性 原理経典』を作り上げた。経典に精通した者は師と仰がれようになった。

しかし精通したものにしか理解できない経典では効果は見込めない。軍議に参加する誰もが真意を正確に理解できなければ意味をなさないと考え『原理経典 注釈』なる巻物も作成した。手抜かりはない。

それらは軍議の場で正確性を記すために用意されたものである。もちろん現場の全ての者に読ませるために『原理経典 要約之巻』を人数分用意した。そんな行き届いた配慮を見せたことも、彼らが師と仰がれる所以なのかもしれない。

やがて大将の思いは実り、あしはら国は信仰を深めることになった。

ある夜、大将はしばらく報告書を見つめたのち、ゆっくりと額に手を当てた。

──なぜ、戦に出ぬのだ。


手段を真似するのではなく、自組織にあった方法で心理的安全性を高めようとする工夫を色々とやってみた結果、結局「ぬるい組織」を作ってしまい、困ったことに「心理的安全性を高めることは素晴らしい」という思いだけが強くなってしまう例です。

私は心理的安全性の効果がないと思っているわけではありません。むしろ「効果的ではないチーム」を「効果的なチーム」にするにはとても有効だと思っていますし、マジシャンとしてはこういう心理的アプローチは積極的に取り入れたいところです。

ではなぜ心理的安全性が有効に働かないケースがあるのか?そもそも高める必要があるのか?「効果的なチーム」を目指す他のアプローチはないのか?

次回、ちょっと真面目に考察。

2025年12月9日火曜日

心理的安全性の物語
第2章:まほろば国の戦い

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

「心理的安全性は効果的らしい」
そんな情報を得て素直にその手法を導入した事例を見てみましょう。


第2章:まほろば国の戦い

まほろば国の大将は苛立っていた。

領土拡大のために各地へ配した精鋭部隊から、勝利の知らせがなかなか届かない。 大将のもとに届く報告は、そのすべてが様式美を纏い、忠義を尽くす書状ばかりであったが、戦況の改善には繋がっていなかった。

奇妙なことに、それぞれの陣はどこもかしこも洗練されていた。軍議はいつも静寂に満ちていた。大将の命に背かぬ戦略であるか、戦うことに大義があるか、褒美の配分は妥当か、戦況報告に粗相がないか、一つひとつ丹念に吟味されていた。

軍議の場は神聖であるとして、刀は持ち込まず少し離れた床几に立てかけてある。 ただし、いつでも出陣できるように、全員が鎧兜を身に着けていた。 それはまるで「慎重」と「即応性」の絶妙な折衷を体現するかのような姿だった。

しかし、陣の洗練ぶりとは裏腹に、戦況は一向に好転しない。

そのときである。

ひとりの若い侍が、この膠着を破る重要な事柄について、震える声で言上した。

「おそれながら……数年前、ググール国の優れた戦略家が残したという戦術書が、密かにこの国にも伝わっておりました。それがここに」

聞けば、その戦術書を取り入れた部隊は驚異的な戦果を挙げたという。将軍はその効果の絶大さに瞠目した。

「すべての隊に、この“心理的安全性”なる戦略を導入せよ!」

その日を境に、各部隊の様子は一変した。 形式を重んじて揃いの鎧兜に身を包んだ者たちは、将軍の命に従い、盾をも装備することになった。

しかも、なぜか両手にひとつずつ……。

最初はどの陣も戸惑いの色を隠せなかったが、将軍の命に背くものは一人もおらず、新たな形式として受け入れた。 盾を持つ角度まで統一して士気を高めながら軍議に臨む。すると、軍議の雰囲気は急速に変わっていった。

熟練の者の指図を一言も漏らさず聞き取る姿勢だった若い者は、いつしか積極的に声を発するようになっていた。 さらには、自らの戦場経験を生かした策をたった一言で厳かに伝えていた者も、いまや笑顔で若い者の意見に耳を傾けている。

「それにしても、この盾……両手にひとつずつ持つものなのでしょうか?」
「いや、わしも初めてじゃが、とにかく絶大な安心感よ。このまま眠れそうなほどじゃ」
「眠られては困りますよ!」
「じゃが、拳骨が飛んでくることもあるまいよ」
「たしかに。みな、両手が塞がっておりますからね」
笑いが起こる。

別の隊でも似たような会話が交わされていた。
「あの、この軍議の記録をつけたいのですが、両手が塞がってしまって……」
「わしも身動きが取れぬのだが、皆が動けぬとなれば、これはこれで安心よな」
「なんと申しますか、“平等”というものを肌で感じますな」
「うむうむ、実によろしい」

軍議の場には、かつてないほどの安堵と笑い声が満ち、夜が更けてもなお語りは尽きなかった。

そして翌朝──
大将のもとに届いた報告書には、どの隊も戦に出る前から満足げに、
「本日も軍議、大いに進捗あり」
「誤解なき発言多数、討議の安全確保」
「隊員一同、これ以上ないほど安心」
「“心理的安全性”の効果は絶大なり」
などと綴られていた。

やがて大将の思いは実り、まほろば国は親交を深めることになった。

ある夜、大将はしばらく報告書を見つめたのち、ゆっくりと額に手を当てた。

──なぜ、戦に出ぬのだ。


なんか安全になりました、という「ぬるい組織」を作るのが目的になってしまう例です。心理的安全性という言葉に引きづられて、意味を履き違えてしまったということで、結構よくみられる勘違いです。

自身や組織にとってリスクのある事柄でも、躊躇せずに発言できると思える環境が「効果的なチーム」を作る、というのが google の研究結果です。リンク先の google の記事を読んでいただければ、決して「ぬるい組織」を目指していないことがわかります。
「効果的なチームとは何か」を知る

それに対して、間違った解釈でぬるい組織をつくる方向に解説している記事が結構あります。幸いなことに、世の中にはこの誤りを指摘する記事もたくさんあります。さらには間違いを指摘するだけではなく、きちんと日本にカスタマイズした手段を提示している記事まであります。

なのですが、、、

私のモヤモヤはこの先にあります。日本にカスタマイズしたやり方で心理的安全性を高めたら、本当に google の言う「効果的なチーム」になるだろうかと。次章、あしはら国の物語。

2025年12月8日月曜日

心理的安全性の物語
第1章:ググール国の戦い

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

 「心理的安全性」という言葉、どうもしっくりこないと感じているのは私だけでしょうか?この手の話はなぜかセンシティブに扱われていて、ちょっと否定的なことは言い難い雰囲気があります。弱者の気持ちが分からない人だと思われるのは避けたいでしょうし、仮に「心理的安全性を高める施策ははとりません」などと言ってしまうと、パワハラ認定されてしまうのではないか心配になる人もいるでしょう。ある程度気遣いができる人なら、少なからずこのリスクを味わっていることと思います。ちなみに、この状況を「心理的安全性が低い」と表現するようですが。。。

今回は、このちょっしたモヤモヤを小説風に表現してみます。3章構成で。


第1章:ググール国の戦い

ググール国の王は苛立っていた。

領土拡大のために各地へ派遣した精鋭部隊から、勝利の知らせがなかなか届かない。 王のもとに届く報告書は、どれも血気盛んな武勇伝ばかりだが、戦況の改善にはつながっていなかった。

ところが、ひとつだけ特異な報告をする隊があった。その隊の報告は、いつも簡潔で、犠牲が少なく、成果だけが静かに積み上がっている。 圧倒的な強さを持つ勇猛な武人がいるのだろうか。それとも知略に富んだ軍師がいるのだろうか。その理由は報告からはわからない。

もし、この隊の秘密を他の隊にも適用できれば、この戦 ── 勝てる!

たったひとつの隊の特異な報告ではある。だがその報告に光明を見出した王は、激励を兼ねて出陣地を訪れることにした。当然、側近たちは反対する。王が城を空けて戦線に出向くような段階ではない、と強く止める声があがった。それらの意見を抑えて、王が自ら戦線に足を運ぶことを決めたのである。この隊の秘密は値千金であると信じて。

しかし、出陣地を訪れた王が目にした光景は、想像とまったく異なるものだった。隊員たちは、お互いに距離を取って円陣を組み、巨大な盾を構えて軍議の場に臨んでいたのである。異様な光景に見えるが、若い剣士も決して怯まずに意見し、熟練の剣士たちはその意見の穴を指摘しつつ、より緻密な戦略に練り上げていく。時に対立する意見を戦わせながらも決して破綻することはなく、議論は洗練された結果を紡いでいく。それはまるで事前に小さな戦をしているような景色だった。王は気がついた。

──この隊にとっては、実際の戦場に赴くことは、幾度目かの再戦に臨むのに等しい。

他の隊との違いはこの異様な軍議にあり。そう看破した王であったが困惑は残る。軍議の終わりを待ち、王は指揮官を呼んだ。

「これほどの戦果を挙げている理由を知りたい。なぜ盾なのだ?」

指揮官は静かにうなずき、経緯を語り始めた。

「最初は、我々も他の隊と変わりませんでした。経験豊富な剣士の言うことに従うしかなく、若い者は声を発することすらためらっていたのです。そこで、全員に剣を持たせて軍議を行うようにしました。剣を握れば若い剣士でも発言するだけの力を得られると考えたのです」

王はうなずいた。剣を持つことは力の象徴だ。発言力の象徴にもなるのだろうと。しかし指揮官は続けた。

「ところが、思わぬ事態を招きました。誰もが剣を持つようになると、いつ斬られるかと互いに牽制し合い、軍議は静まり返ってしまったのです」

王は眉をひそめた。剣はただ疑念を鋭くするのみか。

「そこで私は発想を変え、全員を“剣の届かぬ距離”に立たせ、さらに盾を持たせました」
「盾を?」
「はい。盾があれば、意見が割れた者から切り付けられるのを一度は防げます。その間に周りの者が止めに入りますので大事を避けられる仕組みにしたのです」

指揮官はそこで初めて穏やかな笑みを浮かべた。

「驚いたことに、急に皆が語り始めたのです。誤った作戦でも、愚かな意見でも構わず口にできる。 互いが安全を確かめたことで、ようやく戦の失敗を事前に避けられるようになりました」

これが、あの報告書に書かれた秘密か。王は深く息をついた。盾など兵士の持ち物のひとつにすぎないと思っていた。 だが、この隊にとって盾は、己の身を守るのみならず、互いの言葉を守る道具であった。

「この仕組みを何と呼ぶ?」

「“心理的安全性”と呼んでおります。そもそも実際に危険なことが起きたことはないのですが、起きるかもしれないという疑念を取り払うことが重要と心得ます。剣を持つことよりも、互いの心を守ることが、強い隊をつくるのです」

王は遠くを見つめた。剣を振りかざすばかりの国の未来が、急に薄い霧の向こうに感じられた。そして、盾を掲げる者たちの隊こそ、これからの国を支える礎になると悟った。王は他の隊にも盾を、いや“心理的安全性”を導入することを決めた。そして指揮官を呼び、この理論を戦術書にまとめることを指示した。

帰還した王は、国の未来を託したこの戦術書を各隊に配布し、全隊員を信じて待つことを決めた。

やがて王の思いは実り、ググール国は進攻を深めることになった。


google の組織研究の結果を表現してみたつもりです。要はこうなることを夢見て心理的安全性を取り入れようとするわけです。
で、この手法をそのまま真似して導入するとどうなるか。次章、まほろば国の物語。

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


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

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音の平仮名のどれに該当するかを答える」というところに焦点を当てて、ディープラーニングを使ってみます。
次回は「準備編」。データセットを作るまでの悪戦苦闘をお送りします。

2018年12月24日月曜日

やればできる!

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

やればできる!

今年のアドベントカレンダーは技術的なことに集中していたので、ちょっと脇道に逸れた投稿をしてみることにしました。(Qiita の意図からは外れているかもしれませんが、気にしないことにします)。「不可能への挑戦」のつもりで気合を入れて作業を始めてみたものの、実際にに手を動かしてみると「冬休みの工作」みたいなノリでした。出来上がったものを見てどう感じるかは、あなた次第です!

近所の100円ショップに小さな立方体の木片が2つセットになって売っていました。2つの立方体が重なっている様子を見て「あ。アレ作ってみよう!」と思い至り、急遽工作開始です。使ったのはこの木片2つと、彫刻刀一本。(と、絆創膏一枚...)。出来上がったのは以下のようなものです。
上の立方体には溝を掘り、下の立方体は突起を残して周りを削り落としました。そのふたつの立方体を組み合わせたのが上記の写真です。

他の2面が気になる方のために動画も用意しました。「不可能」と思う前に、実現方法を考えてみてください。
実はこれ、ただのパズルではありません。れっきとした建築技術。「組継(くみつぎ)」と呼ばれる、木材同士を接合する技法です。突起部分が長方形であれば単純に横のズレに強い形になり、これは「あられ組み」と呼ばれます。それをさらに発展させて、引っ張り方向に力を加え続けることで、より強力に接合させるのが「蟻継ぎ」です。突起の形が(写真のように)台形になっており、蟻の頭のような形なので蟻継ぎと呼ばれるようです。ポイントは、面白い形・カッコ良い形を追求したのではなく、実用性を追求していく中で考え出されたアイディアだという事です。実際、釘や接着剤を使わずに蟻継ぎのみで組み上がっている製品はいくつもみられます。

ところで、蟻継ぎは左右のズレに強いだけではなく、上下の緩みもなくすわけですが、前後にはスライドします。そうでなければ嵌められないので当たり前のようですが、前後にも動かないような蟻継ぎが実現できるとしたらどうでしょう。最高の強度になるのではないでしょうか?そんな思いを実現しようという意気込みが「4面すべてに蟻継を施す」というあり得ない発想を生みました。しかも、そのアイディアは、1728年に書かれた「匠家仕口雛形」という本にすでに記述されているのです。当時の建築技術の高さはもちろん、職人さんの飽くなき探究心に脱帽です。

人は「できない」と思った瞬間から考えることをやめてしまいます。実際に解決策があるとしても、「ない」と思ったら探さないので、見つかりません。まず「方法はある」と思って探し始める。そんな習慣が、きっといつか「ひらめき」を与えてくれるはず。

2017年12月24日日曜日

AA-Camera を作る (画像変換編)

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

画像変換

ソースコード

いよいよ画像変換です。前回までの作業で、撮影した画像をそのままファイルに保存するところまではできているので、今回はその画像ファイルを引数に渡されたらテキストに変換して出力するスクリプトを書きます。ソースコードは全部で 100 行もありませんので、全部載せました。順を追ってざっくり解説していきます。
  1 #!/usr/local/bin/python
  2 
  3 import os
  4 import sys
  5 import time
  6 import math
  7 import numpy
  8 import cv2
  9 from PIL import Image, ImageDraw, ImageFont, ImageFilter
 10 from PIL.ImageFont import FreeTypeFont
 11 
 12 args = sys.argv
 13 if (len(args) != 2):
 14     print('Usage: translate_to_aa.py input_image_file')
 15     quit()
 16 
 17 image_file = args[1]
 18 font_file = "/PathToFontFile/OsakaMono.ttf"
 19 
はじめの方の import 文は必要なものを import しているだけですので、説明は省略。
12〜15行目は、引数のチェックです。引数が足りなければエラーにしていますが、簡単なチェックしかしていません。
17行目は、その引数を入力画像ファイル名と信じて image_file 変数に格納しています。ま、お遊びプログラムなので。
18行目は、システムにインストールされているフォントファイルをひとつ定義しています。ここでは等幅フォントを指定します。ここポイントです。
 20 def generateDictAscii(dictAscii, size):
 21     image_font = ImageFont.truetype(font_file, size)
 22     w, h = image_font.getsize('Q')
 23 
 24     buf_image = "./buf.jpg"
 25     for ch in " .-|+*/\\^_'#!":
 26         text_canvas = Image.new("RGB", (w, h), (255, 255, 255))
 27         draw = ImageDraw.Draw(text_canvas)
 28         draw.text((0, -2), ch, font=image_font, fill='#000')
 29         text_canvas.convert('L').save(buf_image, 'JPEG', quality=100, optimize=True)
 30         tm = Image.open(buf_image, "r")
 31         dictAscii[ch] = numpy.asarray(tm)
 32     os.remove(buf_image)
 33 
 34     return w, h
 35 
やっと関数が出てきました。あとで、入力画像を1文字分の領域ずつに分けて、その領域と文字画像を比較する処理を行いますので、比較対象の文字を一文字ずつ画像データとして保持しておきます。python では辞書で管理するのが妥当でしょう。第一引数には空の辞書が渡ってくることを期待しています。第二引数はフォントのサイズが指定されることを期待しています。
関数の最後に w, h という2つの値を返していますが、これは、一文字分の画像データを作成した時の幅と高さです。第二引数に指定されたフォントサイズによって結果が変わってきます。
25行目の for 文がちょっと読みにくいですが、" "(スペース), "/"(スラッシュ), "+"(プラス) 等の、絵を描画するのに使えそうな12文字を1文字ずつ処理しています。
一番やりたいことは、31行目の代入です。文字コードをキーとして、その画像データを値として、12文字分の辞書データを作成しているわけです。
 36 def matchKeyBySAD(dictAscii, imgarray):
 37     minvar = 255 * 255
 38     minkey = ""
 39     var = minvar
 40     for key, value in dictAscii.iteritems():
 41         var = numpy.var(numpy.absolute(imgarray - value))
 42         if var <= minvar:
 43             minvar = var
 44             minkey = key
 45    
 46     return minkey
 47 
いよいよ一番大事なアルゴリズムの登場です。第一引数は先ほど作成した辞書データを想定しています。第二引数には、入力画像データを1文字分のサイズに分割したものが渡ってきます。第二引数で指定されたデータにもっとも近い文字を判定するのが、この関数の役割です。この関数では、SAD(Sum of Absolute Difference) を採用しました。入力画像も比較対象の文字画像も同じサイズの行列データです。python では行列のまま高速に演算できますので単純に引き算をしたいところですが、プラスになる場所とマイナスになる場所が相殺されてしまうと、似た画像として認識されかねません。そこで、差分の絶対値を使うことにしました。
式で表すと以下のようになります。Iは入力画像、Tは比較対象の文字画像です。
ただ、これはちょっと処理に時間がかかりすぎました。そのため、もう一つ比較アルゴリズムを作成しましたのでそちらも書いておきます。
 48 def matchKeyByMOMENT(dictAscii, imgarray):
 49     matchkey = ""
 50     minmatched = 1
 51     for key, value in dictAscii.iteritems():
 52         matched = cv2.matchShapes(imgarray, value, 3, 0.0)
 53         if matched <= minmatched:
 54             minmatched = matched
 55             matchkey = key
 56 
 57     return matchkey
 58
こちらは、opencv という画像処理・画像解析・機械学習などの機能をもったライブラリを使用しています。こんなお遊びプログラムに使うのが申し訳ないくらい良くできたライブラリです。matchShapse() 関数で2つの画像を比較し、戻り値が小さければ小さいほど良く似ている画像であるということを意味しています。こちらの方が早いので、以下の main 関数ではこちらのアルゴリズムを使っています。
 59 if __name__ == '__main__':
 60 
 61     dictAscii = {}
 62     w, h = generateDictAscii(dictAscii, 12)
 63     filename, suffix = os.path.splitext(image_file)
 64 
 65     img = cv2.imread(image_file)
 66     img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
 67     img_gray = cv2.GaussianBlur(img_gray, (3, 3), 0)
 68     canny = cv2.Canny(img_gray, 30, 80)
 69 
 70     cv2.imwrite(filename + "-canny" + suffix, cv2.bitwise_not(canny))
 71     im = Image.open(filename + "-canny" + suffix, "r")
 72 
 73     imgarray = numpy.asarray(im)
 74     imgarray.flags.writeable = True
 75 
 76     x = 0
 77     y = 0
 78     counter = 0
 79 
 80     while (y+h) < im.size[1]:
 81         while (x+w) < im.size[0]:
 82 #           bestmatchkey = matchKeyBySAD(dictAscii, imgarray[y:y+h, x:x+w])
 83             bestmatchkey = matchKeyByMOMENT(dictAscii, imgarray[y:y+h, x:x+w])
 84             imgarray[y:y+h,x:x+w] = dictAscii[bestmatchkey]
 85 
 86             sys.stdout.write(bestmatchkey)
 87 
 88             x += w
 89         x = 0
 90         y += h
 91         sys.stdout.write("\n")
 92 
 93     pillimg = Image.fromarray(numpy.uint8(imgarray))
 94     pillimg.save(filename + "-text" + suffix, "JPEG", quality=100, optimize=True)
さ。やっと main 関数です。ここでのポイントは入力画像の変換です。カラーの画像データをそのまま文字データと比較しても期待通りにはいかないので、まずは 66行目でグレースケールに変換しています。さらに、67行目でガウスぼかしをして、68行目でエッジ検出しています。canny というのはエッジ検出の一つのアルゴリズムですが、これをやる前にあえてぼかしておくことで余計なノイズを減らすことができて、より重要なエッジだけが拾えるようになります。

この画像が、

エッジ検出するとこうなるわけです。
背中の羽のあたりを拡大してみましょう。

こんな感じの線で描かれるのが canny の効果です。
プログラムに戻りましょう。80行目と81行目のループは、エッジ検出した画像を1文字サイズごとに分割しながら文字画像と比較していくためのループです。83行目で一番似ている文字を調べて、それを86行目で出力します。これがやりたかった。
最終的に出力されたテキストデータを表示すると、

こうなりますが、小さすぎて canny との違いがわかりにくいですね。
同じように背中の羽のあたりを拡大してみましょう。



あとは、このスクリプトを前回の写真撮影スクリプトの中から呼び出してやれば完成です。スイッチを押してパシャっと撮影すれば、テキストが出力されます。標準出力に出力していますので、リダイレクトしてファイルに保存して tar で圧縮すればかなり小さなファイルとして保存できます。需要はないでしょうけど。。。

と、こんな終わり方では残念なので、「概要編」の最後に載せたテキスト画像の元になったカラー画像を載せておきます。



まとめ

やっぱり、カラー写真って良いもんだなぁ。(^^)