無限の猿定理というものがある。
ja.wikipedia.org
猿にランダムにタイプライターを叩かせる。(猿はしばしばランダム発生装置のモチーフとして登場する。) 当然、ランダムな文字列が生成される。ある程度の文字数に達した時点で、その中にシェイクスピアのハムレットが現れる確率は0.00…0000分の1 とかで有限である。ということは、試行回数を無限にすれば、いつか必ずハムレットが現れるというわけだ。なんかすごい。
(↑ 厳密じゃない表現なのでもう少しだけ厳密に表現すると「十分に大きい回数試行すれば、ほとんど確実に現れる」となります。)
これをやってみた。
www.youtube.com
15秒に1回、電動ダイス振り器が動き、そのダイスの目によって入力するひらがなを決定する。
これを無限にやれば無限の猿定理を実現できる。よかったね。
ダイスはこんな感じ。10面体と6面体。
電動ダイス振り器の制御はTA7291P + Arduino + PC 。
TA7291Pは3年前ぐらいに生産終了になる前にいっぱい買ったけど、その後ぜんぜんふつうに代替品が出回っているので無意味だった。
さて、このマシンを作るうえで技術的に難しかったポイントは2つ
① ダイスの目を読み取る (文字判定)
② 文字列が言葉になっているかをチェックする (言葉判定)
この記事ではこの2つに焦点を当てて解説する。
ダイスの目を読み取る
これは 色による物体検知 + 畳み込みニューラルネットワーク(CNN) でできた。ただし精度6~7割ぐらい。
色による物体検知
漢のコード直貼りスタイル。(なんかブログに貼ると1行おきに空行が入ってしまう。ごめんなさいね。)
import cv2
import numpy as np
def find_specific_color(frame,AREA_RATIO_THRESHOLD,LOW_COLOR,HIGH_COLOR):
"""
指定した範囲の色の物体の座標を取得する関数
frame: 画像
AREA_RATIO_THRESHOLD: area_ratio未満の塊は無視する
LOW_COLOR: 抽出する色の下限(h,s,v)
HIGH_COLOR: 抽出する色の上限(h,s,v)
"""
# 高さ,幅,チャンネル数
h,w,c = frame.shape
hsv = cv2.cvtColor(frame,cv2.COLOR_BGR2HSV)
# 色を抽出する
ex_img = cv2.inRange(hsv,LOW_COLOR,HIGH_COLOR)
# 輪郭抽出
contours,hierarchy = cv2.findContours(ex_img,cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
# 面積を計算
areas = np.array(list(map(cv2.contourArea,contours)))
if len(areas) == 0 or np.max(areas) / (h*w) < AREA_RATIO_THRESHOLD:
# 見つからなかったらNoneを返す
print("the area is too small")
return None
else:
# 面積が最大の塊の重心を計算し返す
max_idx = np.argmax(areas)
max_area = areas[max_idx]
result = cv2.moments(contours[max_idx])
x = int(result["m10"]/result["m00"])
y = int(result["m01"]/result["m00"])
return (x,y)
def extract_orange_area(path):
frame = cv2.imread(path)
# 位置を抽出
pos = find_specific_color(
frame,
# 0 <= h <= 179 (色相) OpenCVではmax=179なのでR:0(180),G:60,B:120となる
# 0 <= s <= 255 (彩度) 黒や白の値が抽出されるときはこの閾値を大きくする
# 0 <= v <= 255 (明度) これが大きいと明るく,小さいと暗い
np.array([0, 75, 75]), # 色の閾値
np.array([35, 255, 255]) # 色の閾値
)
if pos is not None:
orange_rect = frame [max(pos[1]- 60, 0) : min(pos[1]+ 60,
frame.shape[0]) , max(pos[0]- 60, 0) :
min(pos[0] + 60, frame.shape[1]) ]
cv2.imwrite("C:/dpz_program/live/orange.jpg", orange_rect)
いろんなサイトで調べたコードを切り貼りして、「カメラで撮影した画像の中にオレンジのかたまりが見つかったら、その周り120ピクセル四方をトリミングして保存する」というプログラムをつくったのであった。
こんな感じで切り取られる。
この方法で行く場合の弱点は、「うっかりダイス以外で同じ色の領域があると、そっちを認識してしまうことがある」という点だ。なのでそうならないよう、がんばっている。
がんばりポイント:2つのダイスの色をちがうものにしたり、緑の画用紙を敷いたり、反射光を抑えるためにカメラの回りを付箋で囲ったり…。
今回残念だったのが、10面体が白色だということ。本当は青色の10面体がほしかったのだが、通販で手に入った無地の10面体は白色のみ。(スプレーで青色にすることも検討したがいろいろ面倒なのでやめた。) 白のダイスの検知では、電動ダイス振り器の反射光がノイズとなってしまう。
文字の認識自体は機械学習でやるしかない。
母音のダイスは各面20枚ずつ撮影した。
そしてプログラムにより、各画像を10度ずつ回転させたものを生成した。
from PIL import Image
import numpy as np
import glob
# クラスラベル
labels = ["a","i","u","e","o","n"]
dataset_dir = "label/dataset.npy" # 前処理済みデータ
model_dir = "label/cnn_h5" # 学習済みモデル
# リサイズ設定
resize_settings = (30,30)
# 画像データ
X_train = # 学習
y_train = # 学習ラベル
X_test = # テスト
y_test = # テストラベル
for class_num, label in enumerate(labels):
photos_dir = "label/" + label
# 画像データを取得
files = glob.glob(photos_dir + "/*.png")
#写真を順番に取得
for i,file in enumerate(files):
# 画像を1つ読込
image = Image.open(file)
# 画像をRGBの3色に変換
image = image.convert("RGB")
# 画像のサイズを揃える
image = image.resize(resize_settings)
# 画像を数字の配列変換
data = np.asarray(image)
# テストデータ追加
if i % 4 == 0:
X_test.append(data)
y_test.append(class_num)
# 学習データ傘増し
else:
# -180度から180度まで10度刻みで回転したデータを追加
for angle in range(-180,180, 10):
# 回転
img_r = image.rotate(angle)
# 画像 → 数字の配列変換
data = np.asarray(img_r)
# 追加
X_train.append(data)
y_train.append(class_num)
# X,YがリストなのでTensorflowが扱いやすいようnumpyの配列に変換
X_train = np.array(X_train)
X_test = np.array(X_test)
y_train = np.array(y_train)
y_test = np.array(y_test)
# 前処理済みデータを保存
dataset = (X_train,X_test,y_train,y_test)
np.save(dataset_dir,dataset)
これで、1面あたり 20×36 = 720枚の画像が作られる。6面で 4320枚だ。実際に 4320枚の画像が保存されるわけではなく、dataset.npyという1つのファイルとして保存される。
つまづきポイントは、resizeの設定値である。画像のサイズだ。小さすぎると精度が出ないので、後述の学習の際に精度が出ない時は大きくしてみよう。大きくすればするほど学習に時間がかかるけれども。
さて、4320枚の画像を使って学習させる。予測器の作成だ。
di-acc2.com
↑このサイトに載っているプログラムの微修正でいけた。
import tensorflow
from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Activation,Dropout,Flatten,Dense
from keras.utils import np_utils
import numpy as np
# クラスラベル
labels = ["a","i","u","e","o","n"]
dataset_dir = "label/dataset.npy" # 前処理済みデータ
model_dir = "label/cnn_h5" # 学習済みモデル
# メインの関数定義
def main():
"""
"""
# 保存したnumpyデータ読み込み
X_train,X_test,y_train,y_test = np.load(dataset_dir, allow_pickle=True)
# 0~255の整数範囲になっているため、0~1間に数値が収まるよう正規化
X_train = X_train.astype("float") / X_train.max()
X_test = X_test.astype("float") / X_train.max()
# クラスラベルの正解値は1、他は0になるようワンホット表現を適用
y_train = np_utils.to_categorical(y_train,len(labels))
y_test = np_utils.to_categorical(y_test,len(labels))
"""
②モデル学習&評価
"""
#モデル学習
model = model_train(X_train,y_train)
#モデル評価
evaluate(model,X_test, y_test)
#モデル学習関数
def model_train(X_train,y_train):
model = Sequential()
# 1層目 (畳み込み)
model.add(Conv2D(32,(3,3),padding="same", input_shape=X_train.shape[1:]))
model.add(Activation('relu'))
# 2層目(畳み込み)
model.add(Conv2D(32,(3,3)))
model.add(Activation('relu'))
# 3層目 (Max Pooling)
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.3))
# 4層目 (畳み込み)
model.add(Conv2D(64,(3,3),padding="same"))
model.add(Activation('relu'))
# 5層目 (畳み込み)
model.add(Conv2D(64,(3,3)))
model.add(Activation('relu'))
# 6層目 (Max Pooling)
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.3))
# データを1次元化
model.add(Flatten())
# 7層目 (全結合層)
model.add(Dense(512))
model.add(Activation('relu'))
model.add(Dropout(0.5))
# 出力層(softmaxで0〜1の確率を返す)
model.add(Dense(len(labels)))
model.add(Activation('softmax'))
opt = tensorflow.keras.optimizers.RMSprop()
# 損失関数
model.compile(loss="categorical_crossentropy",
optimizer=opt,
metrics=["accuracy"]
)
# モデル学習
model.fit(X_train,y_train,batch_size=1000,epochs=150)
# モデルの結果を保存
model.save(model_dir)
return model
# 評価用関数
def evaluate(model,X_test,y_test):
# モデル評価
scores = model.evaluate(X_test,y_test,verbose=1)
print("Test Loss: ", scores[0])
print("test Accuracy: ", scores[1])
model = main()
正直、CNNのアルゴリズムをあまり理解できていないのだが、ネットで調べるとどの記事も各層同じような設定だったし、実際それでうまくいった。
予測用のプログラムも同様にコピペ。
from tensorflow import keras
import numpy as np
from PIL import Image
# クラスラベル
labels = ["a","i","u","e","o","n"]
# 実行関数
def predict(path):
X = # 推論データ格納
image = Image.open(path) # 画像読み込み
image = image.convert("RGB") # RGB変換
image = image.resize((30,30)) # リサイズ
data = np.asarray(image) # 数値の配列変換
X.append(data)
X = np.array(X)
# モデル呼び出し
model = keras.models.load_model("aiueon/label/cnn_h5")
# numpy形式のデータXを与えて予測値を得る
model_output = model.predict([X])[0]
# 推定値 argmax()を指定しmodel_outputの配列にある推定値が一番高いインデックスを渡す
predicted = model_output.argmax()
# アウトプット正答率
accuracy = int(model_output[predicted] *100)
print("{0} ({1} %)".format(labels[predicted],accuracy))
return(labels[predicted])
以上の文字認識プログラム群を、母音ダイスの認識用と子音ダイスの認識用でそれぞれ用意した。実際にダイスを振って確かめてみると、母音ダイスは精度9割以上だが、子音ダイスは6~7割程度 (体感であって、実際に計測したわけではない) である。子音は選択肢が多いのと、似た文字が多い (特にNとH、YとT、WとM)のが原因。
これでもかなり試行錯誤があった。
まず、子音に関しては各面20枚の撮影では精度が出ないので各面40枚撮影している。つまり40×10 = 400枚の撮影だ。3時間の単純作業だった。地獄。
しかも、YとT、WとMがどうしても区別つかないことに後から気づき、書き方を変えることにした。当然撮影もやり直し。これらは他の面が真上になったときにも映り込むので、結局ぜんぶ撮影し直した。
↓書き方を工夫して区別しやすくした様子
Nの右側に空白の面が来るように配置を変えたり、
Yの開き度合いを極端にせまくしたり、Wにひげをつけたり。
かなり苦肉の策である。
以上、ダイスの文字認識はいばらの道であった。もう二度とやりたくない。
文字列が言葉になっているかをチェックする
ここからは物理の世界とは完全に切り離された、プログラミングの世界である。
作りたいプログラムの仕様
入力:ランダムに生成された、最長15文字の文字列(カタカナ)
出力:15文字を、最後の3文字、最後の4文字、最後の5文字…、最後の15文字というふうに切り出して日本語辞書に検索をかけていったとき、見つかった言葉のリスト
やりたいこと:
ダイスによってこれまでに作られた文字列が、例えば
"はつるつひそなふてこねのとえねんらんにゆはしこほすけひふつ"
の場合、最後 (=最新) の「つ」によって新たに見つかる言葉は何か、知りたい。
この場合、
入力としては最後の15文字である "ネンランニユハシコホスケヒフツ" であり、
出力としては [ "秘仏" ] になる。秘仏て。
まず、日本語の辞書として、松下言語学習ラボの「日本語を読むための語彙データベース(研究用)」Ver 1.1 (141950語)の重要度順語彙データベース (Top 60894) 重要度順位 00001-60894 (42MB) を利用した。
www17408ui.sakura.ne.jp
この辞書のいいところは、重要度順位が載っているところだ。例えば「カンシン」にはいろんな漢字があるが、この辞書で最も重要度が高いのは「関心」である。そこで、もし「カンシン」という言葉が出た場合は「感心」や「歓心」ではなく「関心」と表示することができる。
さて、ダイスによってこれまでに作られた文字列に対し、なぜ最後の15文字だけ調べればよいのかと言うと、辞書に登場する最も長い言葉が15文字だからだ。(当初これを考慮せず「これまでの文字列すべての検索にかける」というスタイルでやったところ、後述の「撥音・拗音・濁音・半濁音を加味した検索」の副作用もあいまって組み合わせ爆発を起こし、検索時間がものすごく長くなった。)
www.youtube.com
↑これ好きすぎて100回は見た。
また、やっかいなのが、撥音・拗音・濁音・半濁音・伸ばし棒を加味した検索である。今回は46字しか扱わないので、ふつうに検索するだけでは撥音・拗音・濁音・半濁音・伸ばし棒をひとつでも含む単語が引っかからない。そこを、無理やり検索に出てくるようにしたい。例えば「シヤアヘツト」という文字列に対し「シャーベット」がヒットするようにしたい。これはもうプログラミングのちからで頑張るしかない。
先に述べた「入力」に対し適切な「出力」をしてくれるプログラムがこちらである。使用の際は detectWord(mojiretsu) を呼び出せばよい。
#%%
import pandas as pd
# VDRJ_Ver1_1_Research_Top60894.xlsxのN列、P列、Q列を抜き出し保存したもの
df = pd.read_csv('VDRJ_Ver1_1_Research_Top60894.csv', encoding = 'utf-8')
df = df.iloc[::-1] # 重要度が高いものが後に来るよう逆順にする
# 伸ばし棒対策 (絶対もっとスマートな書き方ある)
df['kana'] = df['kana'].str.replace("アー", "アア").
str.replace("イー", "イイ").str.replace("ウー", "ウウ").
str.replace("エー", "エエ").str.replace("オー", "オオ").
str.replace("カー", "カア").str.replace("キー", "キイ").
str.replace("クー", "クウ").str.replace("ケー", "ケエ").
str.replace("コー", "コオ").str.replace("ガー", "ガア").
str.replace("ギー", "ギイ").str.replace("グー", "グウ").
str.replace("ゲー", "ゲエ").str.replace("ゴー", "ゴオ").
str.replace("サー", "サア").str.replace("シー", "シイ").
str.replace("スー", "スウ").str.replace("セー", "セエ").
str.replace("ソー", "ソオ").str.replace("ザー", "ザア").
str.replace("ジー", "ジイ").str.replace("ズー", "ズウ").
str.replace("ゼー", "ゼエ").str.replace("ゾー", "ゾオ").
str.replace("ター", "タア").str.replace("チー", "チイ").
str.replace("ツー", "ツウ").str.replace("テー", "テエ").
str.replace("トー", "トオ").str.replace("ダー", "ダア").
str.replace("ヂー", "ヂイ").str.replace("ヅー", "ヅウ").
str.replace("デー", "デエ").str.replace("ドー", "ドオ").
str.replace("ナー", "ナア").str.replace("ニー", "ニイ").
str.replace("ヌー", "ヌウ").str.replace("ネー", "ネエ").
str.replace("ノー", "ノオ").str.replace("ハー", "ハア").
str.replace("ヒー", "ヒイ").str.replace("フー", "フウ").
str.replace("ヘー", "ヘエ").str.replace("ホー", "ホオ").
str.replace("バー", "バア").str.replace("ビー", "ビイ").
str.replace("ブー", "ブウ").str.replace("ベー", "ベエ").
str.replace("ボー", "ボオ").str.replace("パー", "パア").
str.replace("ピー", "ピイ").str.replace("プー", "プウ").
str.replace("ペー", "ペエ").str.replace("ポー", "ポオ").
str.replace("マー", "マア").str.replace("ミー", "ミイ").
str.replace("ムー", "ムウ").str.replace("メー", "メエ").
str.replace("モー", "モオ").str.replace("ヤー", "ヤア").
str.replace("ユー", "ユウ").str.replace("ヨー", "ヨオ").
str.replace("ラー", "ラア").str.replace("リー", "リイ").
str.replace("ルー", "ルウ").str.replace("レー", "レエ").
str.replace("ロー", "ロオ").str.replace("ワー", "ワア").
str.replace("ァー", "アア").str.replace("ィー", "イイ").
str.replace("ゥー", "ウウ").str.replace("ェー", "エエ").
str.replace("ォー", "オオ").str.replace("ャー", "ヤア").
str.replace("ュー", "ユウ").str.replace("ョー", "ヨオ").
str.replace("ヮー", "ワア")
# 日本語辞書(本物)を辞書型変数に (ついにこの時が来た)
hyouki_dict = dict(zip(df["kana"], df["hyouki"]))
hinshi_dict = dict(zip(df["kana"], df["hinshi"]))
# 撥音・拗音・濁音・半濁音対策 (絶対もっとうまいやり方ある)
dakuten = {
'ア': ['ァ'],
'イ': ['ィ'],
'ウ': ['ゥ'],
'エ': ['ェ'],
'オ': ['ォ'],
'カ': ['ガ'],
'キ': ['ギ'],
'ク': ['グ'],
'ケ': ['ゲ'],
'コ': ['ゴ'],
'サ': ['ザ'],
'シ': ['ジ'],
'ス': ['ズ'],
'セ': ['ゼ'],
'ソ': ['ゾ'],
'タ': ['ダ'],
'チ': ['ヂ'],
'ツ': ['ッ', 'ヅ'],
'テ': ['デ'],
'ト': ['ド'],
'ハ': ['バ', 'パ'],
'ヒ': ['ビ', 'ピ'],
'フ': ['ブ', 'プ'],
'ヘ': ['ベ', 'ペ'],
'ホ': ['ボ', 'ポ'],
'ヤ': ['ャ'],
'ユ': ['ュ'],
'ヨ': ['ョ'],
'ワ': ['ヮ'],
}
# 撥音・拗音・濁音・半濁音のバリエーション作成
def generate_combinations(input_str):
combinations = [input_str]
for i, char in enumerate(input_str):
if char in dakuten:
new_combinations =
for combo in combinations:
for replacement in dakuten[char]:
new_combinations.append(combo[:i] + replacement + combo[i + 1:])
combinations.extend(new_combinations)
return combinations
def detectWord(mojiretsu):
detected_word = []
for i in range (2, len(mojiretsu) + 1):
ngram = mojiretsu[-1 * i :]
ngram_dakuten_list = generate_combinations(ngram)
for ngram_dakuten in ngram_dakuten_list:
if len(ngram_dakuten) > 2 and ngram_dakuten in hyouki_dict.keys():
detected_word.append(hyouki_dict[ngram_dakuten])
return detected_word
generate_combinations(input_str) によって、「シヤアヘツト」という文字列から「シャアヘツト」「シャァヘツト」「シャァベツト」…のように、あらゆる撥音・拗音・濁音・半濁音の可能性を網羅した文字列リストが作られる。この部分は真面目に考えると大変なので、ChatGPTにつくってもらった。
すごっ。これ自分で作るの大変だ…。競技プログラミングっぽさがある。「これはもうプログラミングのちからで頑張るしかない。」と偉そうなことを言っておきながら、結局AIに頼るのであった。
できた。よかったね。
以上、無限の猿定理マシンのテクニカルな部分の解説でした。