株式会社ホクソエムのブログ

R, Python, データ分析, 機械学習

書籍「評価指標入門」の出版に寄せて 〜監修の想い(O・MO・I)

監修させていただいている評価指標入門なんですが、株式会社ホクソエムの代表取締役CEOである私、牧山幸史(以下、コージー牧山)、はじめてこの企画を聞いた時は「その特徴は単に評価指標をまとめた辞書やないかい!そういう”売れそうだから書く”みたいな商業的なマインドが学術界の価値を貶め云々」と思ったのですが、上梓された高柳さん(タカヤナギ=サン)の壮大なるお話を聞いているうちに「これはひょっとして数理モデリングとしても奥深い世界が広がっているの?面白いかも!」と思い監修社として名乗りを上げた次第です。

一方、本書の内容と皆様の期待値がややズレているのではないか?と不安には思っておりまして、これは監修社として一肌脱いでおかなければいかんなと、自然界に存在する第5の力「期待値調整力」を見せなければならないなと思い筆を取った次第です。

以下、私、コージー牧山の視点で「書いてあること・書いてないこと・書きそびれた」ことをここに記すことにより潜在、あるいはすでに予約されてしまった方々への期待値調整とさせていただきます。

書いていること

まずMAEやRMSE、またAUCやF値などベタベタな評価指標については網羅的ではないですが説明しています。また「結局、方針としてどういう評価指標を選んだらいいんだ?」という問いに対する答えも用意しており、その”答え”を御社のビジネスに適用するための考え方、及びその考え方に則ったいくつかの例を示しています。コージー牧山的には評価指標自体よりも、”考え方”の方が重要だと考えており、そちらの点を厚めにやって欲しいなと執筆陣にお願いしこのような形となりました。こう考える詳しい理由は次の”書いていないこと”を参照ください。

書いていないこと

逆に書いていないことでいうと「学術書・論文から評価指標を徹底的に調べ上げ全列挙」はしていません。これは本質的に無意味です。なぜなら、仮に全列挙していたとして、読者が「どこかに私の問題を解くための答えがあるんじゃないか?」と期待し辞書を”A”から”Z”まで全てBrute Forceに探索するようなものだからです。しかし、そもそもそこに答えはないと。結局のところ、”私の問題”が固有すぎてヘルスチェックの意味で教科書的な評価指標を使えはせよ、都度都度自分たちのやっていることに対して合わせて妥当な結論を出すべきであるとコージー牧山は考えます。私以外私じゃないんです、当たり前だけどね。

書きそびれたこと

相当な言い訳ですが、今回はスケジュールやコージー牧山・執筆陣・編集含めた制作部隊の楽屋/舞台裏がかなりグチャグチャしてしまったため、書き損ねている話が結構出てしまいました。例えば

  • カリブレーションとは何か?どういう場面で重要になるのか?評価指標とどう関連するのか?
  • 推薦/ランキングでの評価指標
  • LTVなど評価指標を用いて陽に書き下すことが難しい問題への処方箋
  • データサイエンティストなら誰もが持っていた第六感
  • AIの心を壊すとある星の光
  • PoC死の他にあった4つの結末

などがあります。少なくとも1つ目については追々、本BLOGか技術評論社さんのサイト上で記事として公開したいと思います。

以上、ご購入検討の際に本文章がお役に立つと幸いです。

※この文章は株式会社ホクソエム開発”HoxoGPT”のサポートを受け執筆されました

RでCQT(Constant-Q変換)をやってみる

ホクソエムサポーターの松本です。音楽を作ったり聴いたりするのが趣味なので、音楽分析に興味があります。音データの分析にはPythonだとlibrosaというとても便利なパッケージがあるのですが、Rにはそういった汎用的なパッケージがなくてちょっと不便です。 最近ふとRでCQT(Constant-Q変換)をしてみたいと思い、既存のパッケージを使ってできないか探してみたところ特に見つからなかったので、どのように実装すればいいのか調べてみました。

スペクトログラムについて

音声や音楽データの分析を行う際には生の波形をそのまま扱うのではなく、スペクトログラム(時間周波数表現)に変換したものを特徴量として利用することがあります。下の画像は「あいうえお」という音声を録音したデータを表したものです。

f:id:matsumototo180:20220330172638p:plainf:id:matsumototo180:20220330172709p:plain

左図の波形データは横軸は時間、縦軸は振幅を表します。右図のスペクトログラムは横軸は時間、縦軸は周波数、色はその時間・周波数の成分の強度を表します。

このように視覚化した際、波形データは音の大きさの変化くらいしか分かりませんが、スペクトログラムは周波数ごとにどのように時間変化していくか等が分かります。視覚的に特徴を捉えやすく、画像のようにも扱うことができるので画像処理の手法を応用した分析や変換にも利用しやすいといった利点があります。

スペクトログラムはSTFT(短時間フーリエ変換)によって算出するのが一般的ですが、この方法の問題点として、音高の観点からすると、低域で周波数分解能が低くなるという点があります。人間の聴覚特性として低域では周波数の違いに敏感である(周波数分解能が高い)が、高域になるにつれて鈍感になる(周波数分解能が低くなっていく)という特性があります。音高の国際的な基準はラ(A)=440Hzとなっていますが、1オクターブ上のラは880Hzでちょうど二倍の周波数になります。1オクターブの間には12個の音階があり、これらは公比 $\sqrt[12]{2}$ の等比数列となっています。

STFTでは切り取った信号に対して全ての周波数で同じ窓幅でフーリエ変換を行いますが、CQTではこうした特性を考慮して対数周波数を利用し、かつ周波数ごとに窓幅を変化させて変換することで、音高に対応して周波数分解能を変化させています。

実装

以下ではピアノ演奏を録音した音源を使って、各種プロットを行う例を示します。

音源の読み込みには tuneR パッケージ、スペクトログラムのプロットには seewave パッケージを利用しています。

library(tuneR)
library(seewave)

# wavファイルの読み込み
wav <- readWave("pf.wav")

# 波形のプロット
plot((seq(wav@left) - 1) * 1 / wav@samp.rate, wav@left, type="l", xlab="time", ylab="amplitude")

# STFTによるスペクトログラムのプロット
ggspectro(wav, ovlp=50) + 
  scale_y_continuous() +
  geom_tile(aes(fill=amplitude))
f:id:matsumototo180:20220330172724p:plainf:id:matsumototo180:20220330172722p:plain


次にCQTを算出してみます。実装はこちらの記事を参考にさせて頂きました。 音楽プログラミングの超入門(仮): 【Python】 Constant-Q 変換 (対数周波数スペクトログラム)

library(signal) # ハミング窓を使うためにsignalパッケージを利用

# パラメータの設定
qrate <- 1
fmin <- 60
fmax <- 6000
bins_per_octave <- 12
hop_length <- 512
two_pi_j <- 2 * pi * 1i

# 対数周波数ビンの数とそれらに対応する周波数を算出
nfreq <- round(log2(fmax / fmin) / (1 / bins_per_octave)) + 1
freqs <- fmin * (2 ** ((seq(nfreq) - 1) * (1 / bins_per_octave)))

# CQTを行うフレーム数とそれらに対応する時間を算出
nframe <- ceiling(length(wav@left) / hop_length) + 1
times <- seq(nframe) * hop_length * (1 / wav@samp.rate)

# 窓幅を設定するためのパラメータ
Q <- (1 / ((2 ** (1 / bins_per_octave)) - 1)) * qrate

# CQTスペクトログラムのための行列を初期化
ret <- matrix(0, nframe, nfreq)

# 周波数ごとに窓幅を変えてCQT
for (k in seq(nfreq)) {
  nsample <- round(wav@samp.rate * Q / freqs[k])
  hsample <- round(nsample / 2)
  
  phase <- exp(-two_pi_j * Q * seq(nsample) / nsample)
  weight <- phase * hamming(nsample)
  
  # 各フレームごとに信号を切り取る→重みを掛けてretに代入
  for (iiter in seq(nframe)){
    iframe <- iiter
    t <- (iframe - 1) * hop_length * (1 / wav@samp.rate)
    istart <- (iframe - 1) * hop_length - hsample
    iend <- istart + nsample
    sig_start <- min(max(1, istart + 1), length(wav@left))
    sig_end <- min(max(0, iend), length(wav@left))
    win_start <- min(max(1, sig_start - istart), nsample)
    win_end <- min(max(0, length(wav@left) - istart), nsample)
    win_slice <- weight[win_start : win_end]
    y <- wav@left[sig_start : sig_end]
    ret[iiter, k] <- sum(y * win_slice) / nsample
  }
}


次に算出したCQTスペクトログラムをggplotを使ってプロットしてみます。実装はseewaveパッケージのggspectroを参考にさせて頂きました。https://github.com/cran/seewave

library(ggplot2)

P <- abs(t(ret)) # 絶対値に変換し、振幅を取り出す
P <- P/max(P) # ノーマライズ
P[P == 0] <- .Machine$double.eps # log10(0)で-Infにならないようにする
P <- 20*log10(P) # dBスケールに変換

# 外れ値で表示が変にならないように範囲を設定
limit_upper <- 0
limit_lower <- -50

# ggplotで表示するためのデータフレームを作成
frequency <- rep(freqs, times=ncol(P))
time <- rep(times, each=nrow(P))
amplitude <- as.vector(P)
amplitude[amplitude < limit_lower] <- limit_lower
df <- data.frame(time, frequency, amplitude)

tlab <- "Time (s)"
flab <- "Frequency (kHz)"

# CQTスペクトログラムのプロット
ggplot(df, aes_string(x="time", y="frequency", z = "amplitude")) + 
  xlab(tlab) + 
  ylab(flab) + 
  scale_y_log10(breaks=freqs[seq(1, length(freqs), 12)]) +
  geom_tile(aes(fill=amplitude))

f:id:matsumototo180:20220330172719p:plain

CQTのスペクトログラムの算出とプロットを行うことができました。

プロットを見ると比較的はっきりとピアノで演奏された音が浮き上がっていることが分かります。CQTはメロディーや和音など音楽的な要素の分析によく使われる特徴量で、音楽分析においてはSTFTを使うよりも有用な場合が多いと思います。

参考文献

機械学習とビジネスを橋渡しするものこそ評価指標であり, ”全てのビジネスは条件付期待値の最大化問題として書ける”仮説についての一考察

はじめに

株式会社ホクソエム常務取締役のタカヤナギ=サンです、データサイエンスや意思決定のプロ・経営をしています。

掲題の件、現在、某社さんと”機械学習における評価指標とビジネスの関係、および宇宙の全て”というタイトルの書籍を書いているのですが、 本記事のタイトルにあるような考え方については、論文・書籍などを数多く調査しても未だお目にかかることができず、これをいきなり書籍にしてAmazonレビューなどでフルボッコに叩かれて炎上して枕を涙で濡らすよりも、ある程度小出しにして様々な人々の意見を聞いた方が良いのではないかと思い独断で筆を取った次第です。

筋が良さそうなら論文にするのも良いと思っている。

「いや、そんなもん会社のBLOGに書くんじゃねーよ💢」という話があるかもしれないですが、ここは私の保有する会社なので何の問題もない、don't you?

こういうビジネスを考えてみよう

「この人、家賃を滞納しそう?」AIが予測 入居審査を45分→16分に - ITmedia NEWS

という話を見かけたので、これに似た題材にしてみようと思う。

問題を簡単にするため、

  • 貴方は家賃保証会社の経営者としてN人の入居希望者に対し、家賃保証審査を行う
  • 貴方にできることは各入居希望者と家賃保証契約を「する・しない(結ぶ・結ばない)」を選ぶことだけ
  • 家賃保証契約をする場合(入居希望者に実際に入居して貰う場合)
    • ある期間(1年とか3年とか)において一度も滞納することなく家賃を払い続けてくれたら、入居者からS 円/人の保証料を手に入れる(これが売上)
    • ある人が1回でも家賃を滞納したら、その人からもらったS円の保証料全額と保証会社としてD円の金額を上乗せして家のオーナーにお渡しする(これが家賃保証会社として見たときの損失)
  • 家賃保証契約をしない場合
    • その入居希望者からは何のお金ももらえない(家賃保証契約してないので当たり前)

という設定で考えてみよう。

人件費や家賃などなどのコストは一切考えないことにし、この「家賃保証料 - 滞納への保証金」がこの家賃保証会社の利益です。

そのビジネス、こういう条件付期待値でかけまっせ

このビジネスの利益(ビジネス施策によって条件つけられた条件付き期待利益)をPと書くと、これは以下のように書ける。 ここは所謂「ビジネスの数理モデリング」で、各社・ビジネスによって異なる部分です。 最近はここをどれだけうまく書き下せるかが焦点だと考えています。


    P =
    \sum_{i=1}^N
    \mathbb{E}
    \left[
        \left(
            S \mathbf{1}_{\left\{F^i\right\}}
            - 
            D(1 - \mathbf{1}_{\left\{F^i\right\}}) 
        \right)
        \mathbf{1}_{\left\{ a_i = \text{Guarantee} \right\}}
        \mid 
        a_i
    \right]

S、Dは先程説明したとおりの保証料としての儲けとうっかり滞納された場合の損失で 、

\mathbf{1}_{\left\{\dots\right\}}

{} の中がTRUEなら1, そうでなければ0になるような指示関数であり、  F^ i は「ユーザ i が家賃を滞納しない優良な入居者ならTRUE、そうじゃない(家賃を滞納すれば!)ならFALSE」となるような論理変数です。 同様に a_i = \text{Guarantee} は「ビジネス施策として家賃保証する(Guarantee)場合にはTRUEで、そうでなければFALSE」となるような論理変数です。 ようするにユーザiに対するビジネス施策(ここでは家賃保証契約をするかしないかということ)のこと。

重要なポイントは

  • 機械学習によるビジネス成果(ここでは利益向上)は、この  a_i を通じてしか影響しえない
  •  a_iは別に機械学習で決めなくてもよく、人間が赤ペンをなめて適当に決めても良い

という点です。 「機械学習は所詮ビジネスにとってのおまけ、あってもなくても良い」としばしば言われる所以はここにあるのです。

しかし、Nがとても大きい場合には、各々の入居希望者iに対して適切なビジネス施策(ここでは家賃保証契約をするかしないか)を提供することは、 人間がやるには大変な手間とコストがかかるため敬遠される作業であり、機械学習やデータサイエンスを極めている我々の出番になるわけです。

家賃を保証するかしないか、まさに2値クラス分類であり、プロのデータサイエンティストには朝飯前。

サクッとやろうぜ、と。

従って、以下の議論では  a_i は何らかの2値クラス分類モデルで決定するとします。 ここでは"家賃を滞納しない入居者”をPositiveなケースとします。 従って、"家賃を滞納してしまう入居者"のケースがNegativeなケースです。

評価指標 〜機械学習とビジネスを橋渡しするもの〜

ここまでの話と機械学習や評価指標がどうつながっているのか、まだ皆さんその気配を感じれてないと思います。 ですので、ここで、少し先ほどの数式を変形してみましょう。


    P =
    \sum_{i=1}^N
    \left\{
        \left(
            S + D
        \right)
        \mathbb{E}
        \left[
            \mathbf{1}_{\left\{F^i\right\}} 
            \mathbf{1}_{\left\{ a_i = \text{Guarantee} \right\}}
            \mid 
            a_i
        \right]
        -
        D
        \mathbb{E}
        \left[
            \mathbf{1}_{\left\{ a_i = \text{Guarantee} \right\}}
            \mid 
            a_i
        \right]
    \right\}

この第一項目の期待値の中身の部分をグッと睨んで見て欲しい。 この期待値の中身が1となるのは「実際に入居者が家賃を滞納しなかった、かつ、機械学習モデルの予測結果として"家賃保証契約をする"となっているケース」であり、これは機械学習の評価指標の文脈で言う True Positive (TP) の場合と近そうじゃないか?

また同様に第二項目の中身の部分をグッと睨んで欲しい。 こっちは「機械学習モデルにおいて「家賃保証契約締結するぞ!」と予測した場合 1 」になるものであり、 これは(True Positive + False Positive)の個数になりそうではなかろうか?

実際、この期待値計算をサンプルサイズ1(つまり、1個人から1データをもらう)としてみたものこそが、我々の手元にあるデータなのであり、我々が唯一手元でいじくり回して機械を学習させるために使えるデータなのである。

実際に計算してみると以下のようになる。


    P =
    \left(
        S + D
    \right)
    \times
    TP
    -
    D
    \times
    (TP + FP)

さらに、もう少し評価指標を全面に押し出した形式で書くと、


    P =
    \left\{
        \left(
            S + D
        \right)
        \times
        \text{Precision}
        -
        D
    \right\}
    (TP + FP)

となる。できた!!!

この数式は美しい形式になっており「家賃保証契約をすると機械学習モデルで決定した人数(TP + FP)に、機械学習モデルの評価指標であるPrecisionを適当な重みをかけて、計算したもの」という形式になっている。

しばしば私が受ける質問として「2値クラス分類の予測確率のしきい値をどのように決めたら良いでしょうか?」というものがあるが、ここで紹介している問題については今それを答えることができます。 実際に皆さんが機械学習を活用して、このビジネスモデル(利益形式)の商売をしている場合、 家賃保証をする・しない2値分類問題の予測確率のしきい値は、この利益が最大になるようなしきい値を使ってやればよいということになる。 すなわち、ここではPrecision(とTP+FP)という評価指標が機械学習とビジネスを橋渡ししており、家賃保証する・しないを決める予測確率の閾値を制御することで条件付期待値の最大化問題を解き、利益を最大化することができるのだ!やった!

ポイントとしては、 ビジネスモデル(利益計算)がデータサイエンスの教科書どおりのAUCやF値、PrecisionやRecallとはちょっとずれてくる点です。 ここが非常に重要で、この”ズレ”こそが機械学習をビジネスに適用した際に「あ、あれ?なんかうまく儲かってなくね?」となる一要因だとタカヤナギ=サンは考えています。 そして、厄介なことに「この利益計算こそがビジネスの根幹であり、ある程度の分類はできるだろうが、それは会社ごとに異なるので一般論を打ち立て得ない」という点です。 少なくともビジネスモデル(SaaSモデルなのか売り切りモデルなのかなどなど)ごとには考える必要があるのです。

「君が機械学習において何を大事にしたいか?それは各社の皆さんのビジネスモデル次第、や!頑張って考えようぜ!」ということなのです。

まとめ

ここでは家賃保証会社のビジネスを通じて、そのビジネスの利益を条件付期待値として書き下し、それを最大化するための方法が実は機械学習の評価指標と密接にリンクしていることを紹介した。

最近考えていることとして、この考え方はビジネス全般において普遍的に成り立つものであり、全てのビジネス(売上、利益などのKPI計算)はこの形式出かけるだろうという仮説を個人的に持っている。

この考え方に明らかなミスや間違いを見つけた方は是非 Shinichi Takayanagi (@_stakaya) | Twitter まで連絡してほしい(そして一緒に本を書いて欲しい)。

今回はPrecisionだけが顔を出してRecallが一切顔を出してこなかった点にも注目して欲しい。 これは「家賃保証契約をしない」場合において、一切の損失が発生しないと見做しているからであり、そこを何がしかの形で取り込むと彼(Recall)も顔を出してくることになる。 この辺はちゃんと書籍にまとめようと思うので期待して欲しい(私が書くとは言ってない&共著者に書かせる!)

pytest fixtureの地味だけど重要な部分について

こんにちは。ホクソエム支援部サポーターのPython担当、藤岡です。 最近はデータエンジニア見習いとしてBI周りを触っています。

今回はpytestのfixtureについての記事です。 pytest自体が有名で記事もたくさんあるので、今回は地味だけど重要だと個人的に思っている usefixturesとスコープについて取り上げます。

地味とはいえ、pytestの初心者がfixtureを使いこなすためのステップアップに必要な内容だと思います。 ぜひマスターしていただければ幸いです。

1. 前書き

  • 基礎的なことに関してはこの記事にとても簡潔にまとまっているので、こちらをまず読むのがオススメです。とても良い記事です。
  • pytestは独自の書き方を持ち込んでいるライブラリです。その機能を使いこなすと「綺麗」なコードにはなりますが、反面それは使われている機能を知らない人にとってはこの上なく読みにくいものです。やりすぎて可読性が下がらないよう、用法用量を守りましょう。
  • 本稿の環境はこちらのリポジトリからcloneできますので、試しながら読んでみてください。

2. fixtureとusefixtures

pytestのfixtureの機能としてもっとも基本的なものがオブジェクトの生成です。 例えば、

@fixture
def values():
    return [2, 1, 3]

@fixture
def sorted_values():
    return [1, 2, 3]

def test_sorted(values, sorted_values):
    assert sorted(values) == sorted_values

といったようなものです。 おそらく、fixtureのイメージとして一番強いのがこの使い方ではないでしょうか。

しかし、実際はそれだけに止まりません。

例えば、機械学習のコードなどでは乱数が使用されているため、結果を固定するには乱数シードの固定が必要です。 こうした処理をfixtureとして用意するとこのようになります。

import random

@fixture
def set_seed():
    random.seed(0)

このように、何も返さず、テストにただ前処理を施すのもfixtureの機能なのです。

さて、少し定義の話をします。 "test fixture"を辞書で引くと「試験装置」と出てきます。 Wikipediaの言葉を借りればtest "environment"、つまり環境です。

なので、入出力のオブジェクトはもちろんのこと、乱数シードの固定、データベースやサーバへのコネクション(のスタブ)の確立、さらにファイルやフォルダの生成/削除などもfixtureであり、基本的にはfixtureデコレータを使って実装するべきものです。

話を戻しますが、何かしらの処理だけをして値を返さないfixtureはテストケースの引数として渡すのは不適切です。 こういった場面では、usefixturesデコレータを使うことでテスト前にfixtureの処理を実行することができます。

@pytest.mark.usefixtures('set_seed')
def test_fix_seed():
    rand1 = random.random()
    ramdom.set_seed(0)
    rand2 = random.random()
    assert rand1 == rand2

しかし、この例ではシードの固定を内部でも一回やっていてイマイチです。

というわけで、今度はシードの初期化をさせるのではなく、その処理をするコールバックを返すことで解決します。

import random

@fixture
def seed_setter():
    return lambda: random.seed(0)

def test_fix_seed_2(seed_setter):
    seed_setter()
    rand1 = random.random()
    seed_setter()
    rand2 = random.random()
    assert rand1 == rand2

関数を返すのは公式でも使用されているテクニックです。 例えば、predefinedなfixtureには一時ディレクトリのパスを返すtmpdirがあるのですが、 一時ディレクトリを生成するためのコールバックtmpdir_factoryもあります。

もちろん、fixtureではなくヘルパ関数としてseed_setterを定義して呼び出すという選択肢もあるので、ケースバイケースで選択しましょう。 上記の例ではヘルパ関数の方がいいと思いますが、乱数シードの固定が至る所で使われるならばfixtureの方がいいです。

他にusefixturesを使う例として、unittestpatchがあります。 下のサンプルコードでは、mymodule.ObjectWithDBconnectメソッドをMagicMockに置き換えています。 これをusefixturesで宣言すれば、データベースコネクションをスキップしてObjectWithDBを使えます。

from unittest.mock import patch
from mymodule import ObjectWithDB

@fixture
def ignore_db_connection():
    with patch("mymodule.ObjectWithDB.connect"):
        yield

usefixtureはとても便利ですが、テストケース以外では使えないという点に注意してください。 例えば、以下のようなことはできません (エラーは吐きませんが、無視されます)。

@fixture
@pytest.mark.usefixtures('set_seed')
def random_value():
    return random.random()

代わりにこうしておけばOKです。

@fixture
def random_value_factory(seed_setter):
    seed_setter()
    return random.random()

3. fixtureスコープと変数スコープ

fixtureは基本的にはテストケースごとに実行されます。

以下のサンプルコードで確かめてみましょう(pytest コマンドに-s オプションを付けるとprint出力が見られます)。

@fixture
def foo():
    print("start")
    yield
    print("end")

def test_1(foo):
    print("test1")

def test_2(foo):
    print("test2")

start -> test1 -> end -> start -> test2 > end の順番でプリントされ、テストごとにfixtureの処理が実行されています。

これは再現性の観点からは良いのですが、その反面オーバーヘッドが発生します。

例えば、テスト用のデータセットにアクセスするfixtureがあったとします。 一回に3秒の初期化がかかったとして、1,000のテストケースで使用されるとしたら、それだけで50分かかります。

そこで、試しにテスト実行順をstart -> test1 -> test2 > endというように変更してみます。 そのためには、pytest.fixtureの引数にscope="session"を加えます。

@fixture(scope="session")
def foo_session():
    print("start")
    yield
    print("end")

実行してみると、意図した通りの挙動になっていることが分かります。 このように、fixtureにおける実行タイミング、つまりいつyieldreturn)に入って、いつyieldに戻る(returnの場合は特になし)のかを決定するためには、 scopeというパラメータを設定します。

変数のスコープと混同するので、本稿ではそれぞれ変数スコープ、fixtureスコープと呼ぶことにします。

fixtureスコープは以下の4種類があり、それぞれ変数スコープとよく似た入れ子状のブロックとしてのまとまりを持ちます。

  • そのテストケース自身のみ*1を含む最小単位であるfuntionスコープ (デフォルト)。
  • クラスの内部の変数スコープと対応する、classスコープ。
  • 一つのモジュールの変数スコープと対応するmoduleスコープ。
  • 全てのテストケース/fixtureを含むsession(package)スコープ。

functionスコープ以外では、最初にyieldした(returnした)結果をキャッシュして同じスコープのテストに渡して、そのスコープの終端でyield後の処理を実施しています。 これは、test_1test_2のそれぞれについて、同じオブジェクトIDのオブジェクトが渡されていることからも確かめられます。

fixtureスコープは基本的には狭いものを使用しましょう。つまり、デフォルトから変更しないのがベストです。 多少の時間的なオーバーヘッドがある場合でも、問題にならないうちは広げるべきではないでしょう。 というのも、キャッシュするという性質上、広いスコープのfixtureを使い回すとそのテスト間に 予期しない依存関係が生じてしまう恐れがあるためです。 次節以降で詳しく解説していきます。

4. fixtureスコープの落とし穴

さて、以下のテストには問題があります。どこか分かりますか?

@fixture(scope="session")
def ids():
    return [3, 1, 4]

def test_ids_sort(ids):
    ids.sort()
    assert ids == [1, 3, 4]

def test_ids_pop(ids):
    ids.pop()
    assert ids == [3, 1]  # fail here

わからない場合は実行してみましょう。すると、以下の行を含むログが表示されます。

E       assert [1, 3] == [3, 1]
E         At index 0 diff: 1 != 3

どうやら、idstest_ids_sortの中でソートされた後にそのままtest_ids_popに渡されてしまっているようです。 キャッシュした値がうっかり破壊的処理によって書き変わってしまう、典型的なバグです。

今回の場合は簡単に分かる話ですが、実際にこのバグに遭遇する場合はたいていもっと厄介です。 現実には、同じfixtureを使うテストが別々のスクリプトに点在している場合もあります。 加えて、テストがバグっている場合、元のソースがバグっている場合の間で区別がしづらいのも問題です。 さらに、今回のケースだとtest_ids_popだけをテストしてやると通ってしまいます(PyCharmであれば簡単にできます)。

こんな事例を想像してみてください。 あなたは新しくテストをいくつか追加しました。それらが通ることは確認済みです。しかしpushして「さあ帰るぞ」と支度をしていたら、CIからエラーが返ってきてしまいました。 どうやら、まったく弄っていない別のテストがエラーを吐いているようです。でも、そのテストだけを走らせてみるとエラーが再現できません……。 残業中なら、xfailを付けて逃げたくなるような話です。

言うまでもないですが、この依存関係を利用するなんてことは論外です。

他にも、広いfixtureスコープのfixtureから狭いfixtureスコープのfixtureは呼び出せないという制限があるので、 無闇に広げるとこの制限に引っかかります。 例えば、以下のfixtureを呼び出すとエラーを吐きます。

@fixture
def foo():

@fixture(scope="session")
def foo_session(foo):
    ...

ただ、どうしてもfixtureスコープを広げたい場合もありますので、 その場合には以下の事項に気をつけましょう。

  • 渡すオブジェクトがimmutableかどうか。
    • 極力immutableなオブジェクトを渡す。
    • mutableオブジェクトならば、テストやテストされる関数等で破壊的なメソッドを呼ばないように細心の注意を払う。
  • immutableオブジェクトでも、DBコネクション等の外部参照をするfixtureを渡す場合には、それがテストごとにリセットされるかどうか(リセット用fixtureを作って常に使うようにするのも手です。)。

5. fixtureの可用範囲

これまでの例ではコードスニペットだけを扱ってきましたが、実際のテストスクリプトは複数のテストケース、それらをもつクラス、果ては複数のスクリプトにまたがります。 fixtureのスコープだけでなく、fixtureの可用範囲、変数でいうところの変数スコープを理解する必要が出てきます。

本節ではその内容について解説します。

まず、基本的には「テストケースが定義された場所」を基準に考えればOKです。

例えば、以下の例ではtest_footest_foo_2は同じような挙動をします。

@fixture
def foo_fixt():
    return "foo"

def test_foo(foo_fixt):
    assert foo_fixt == "foo"

foo_var = "foo"

def test_foo_2():
    assert foo_var == "foo"

テストケースはこのモジュールのグローバル領域に定義されているので、 同じ領域に定義された変数と同様に参照できます。 ここで注意してほしいのが、あくまでグローバル領域であり、これはテストケースの関数ブロックの外側の話です。

クラスが絡むと、この差がもう少しはっきり出てきます。

class TestBar():
    @fixture
    def bar_fixt(self):
        return "bar"

    def test_bar(self, bar_fixt):
        assert bar_fixt == "bar"

    bar_var = "bar"
    ref_bar_var = bar_var

    def test_bar_2(self):
        assert type(self).bar_var == "bar"
    
    @fixture
    def bar_fixt_2(self):
        return type(self).bar_var

    def test_bar_3(self, bar_fixt_2):
        assert bar_fixt_2 == "bar"

クラスブロックでは特殊な名前解決が行われるので、例えばbar_fixt_2からクラス変数bar_varは参照できません。 上の例ではtype(self)を通じてアクセスしています。 一方、クラスブロック内では(当たり前ですが)参照可能なので、クラス変数ref_bar_varの定義時にbar_varを参照できます。

fixtureについても、bar_var同様に直接参照可能です。 テストケースの定義されたブロックで名前解決をしていることが、先ほどの例よりもはっきりと分かります。

さて、さらにテストが大きくなってきた場合を考えてみましょう。 多くのテストケースが作成され、似たようなfixtureが複数のスクリプトに定義されるようになってしまいます。 当然、fixtureを使い回したいという欲求が出てきます(よね?)。

pytestでは、スクリプト間でfixtureを使い回すための仕組みが提供されています。 試しに、conftest.pyという名前のファイルをテストフォルダ直下に作成し、 その中にfixtureを入れてみてください(もちろん、サンプルリポジトリにも用意されています)。 すると、そのfixtureを全てのテストで使うことができます。

このようにconftest.pyは便利なのですが、fixtureをどんどん作成していると次第に汚くなってきます*2

なので、conftest.pyをある程度分割することをオススメします。 conftest.py内で定義されたfixtureの使用可能な範囲は、正確には「conftest.pyの定義されたフォルダとそのサブディレクトリのテスト」です。 なので、テストをサブディレクトリに分割してその中にconftest.pyを作成すれば分割できます。 また、conftest.pyはいわゆるグローバルなオブジェクトが作られてしまうので、 ある程度狭い範囲で利用可能になるように(とはいえconftest.pyが増えすぎないように) するのがベストかなと思います。

余談ですが、筆者は他のファイルで定義したfixtureをconftest.pyでimportすることでconftest.pyを綺麗に保っていたことがあります。 しかし、fixtureのimportは非推奨であり今後のバージョンでの動作は保証されないのでimportはしないようにしましょう *3

6. fixtureの連鎖と階層構造

pytestでは、fixtureを定義する際に別のfixtureを入力として受け取ることが可能です。 知っている方も多いと思うので、ここまでの例でもいくつかの例でこの機能を利用していました。 本節ではさらにその細かい部分に突っ込んでいきます。

6.1 fixtureの循環/再帰エラー

fixtureからfixtureを呼び出すことで、fixtureどうしに有向の依存関係が発生します。 そして、この依存関係を解決する必要があるので、循環や再帰があってはいけません。

# 循環の例
@fixture
def cycle_1(cycle_3):
    return cycle_3

@fixture
def cycle_2(cycle_1):
    return cycle_1

@fixture
def cycle_3(cycle_2):
    return cycle_2

def test_cycle_fixt(cycle_3):
    ...

# 再帰の例
@pytest.fixture
def recursive_fixture(recursive_fixture):
    ...

def test_recursive_fixture(cycle_3):
    ...

上記の例を実行すると、

recursive dependency involving fixture '***' detected

といったようなエラーが発生します。

testからfixtureを呼び出す場合と同様に、fixtureからfixtureを呼び出す場合でも 変数スコープやconftest.pyの階層関係が成立します。 なお、最上位にあたるfixtureはルートディレクトリのconftest内のfixtureかと思いきや、 実はpredefinedなfixtureです*4

6.2 同名fixtureの連鎖

次に、下の例のように同じ名前のfixtureを複数作って、一つ目で二つ目を上書きするような例を考えてみます。

@fixture
def foo_fixture():
    return [1, 2, 3]

@fixture
def foo_fixture(foo_fixture):
    return foo_fixture + [4, 5]

残念ながら、上の例はエラーとなってしまいます。

同一のfixtureを定義した場合、この部分が含まれたモジュールがimportされた場合と同様に、後に定義された方が前に定義された方を上書きしてしまいます。 つまり、一つ目のfoo_fixtureが無視されて二つ目のfoo_fixtureが自身を再帰的に入力としていることになり、上記のエラーが出てしまいます。

しかし、下のように変数スコープを変えることで同じ名前のfixtureを入力とすることが可能です。

@fixture
def foo_fixture():
    return [1, 2, 3]

def test_foo(foo_fixture):
    assert foo_fixture == [1, 2, 3]

class TestFoo():
    @fixture
    def foo_fixture(self, foo_fixture):
        return foo_fixture + [4, 5]

    def test_foo(self, foo_fixture):
        assert foo_fixture == [1, 2, 3, 4, 5]

上の例では、TestFoo.foo_fixtureがglobal領域のfoo_fixtureを引数にとり、それを変形したものを返しています。 このように複数の変数領域に分けることで二つのfixtureの間に上位下位関係が成立して循環と重複がなくなり、 下位のfixtureから上位のfixtureを利用することが可能となります。

「別の名前のfixtureでいいじゃないか……」という意見もあるかと思いますし、役割が大きく変化してしまう場合などにはそれが正しいです。 一方、似通った名前のfixtureを量産することや、fixtureの名前が具体化するにつれて長くなってしまうのは あまり良くありません*5

6.3 親子クラス間での同名fixtureの連鎖

では、最後にクラスを継承した場合はどうなるでしょうか。

以下の例は、ベースとなるfixtureとテストケースを用意して、 それを継承したテストを作成することで様々なパターンのテストの実装を省力化する試みです。

以下のTestInherit.test_inherit_fixtureは通るでしょうか?

class TestBase():
    EXPECTED = [1, 2]

    @fixture
    def inherit_fixture(self):
        return [1, 2]

    def test_inherit_fixture(self, inherit_fixture):
        assert inherit_fixture == self.EXPECTED


class TestInherit(TestBase):
    EXPECTED = [1, 2, 3, 4]

    @fixture
    def inherit_fixture(self, inherit_fixture):
        return inherit_fixture + [3, 4]

正解は、「通らない」です。 これはベースクラスのinherit_fixtureが上書きされるので、再帰的なfixtureとなってエラーを吐きます。

修正案としては、まずそもそもfixtureについてはベースクラスで定義しないでおいて、 ベースクラスをテスト対象から外すような修正をするのが一番だと思います。

どうしてもfixtureも使い回したい場合、 以下のようにベースのfixtureを外に出してしまうという方法があります。

@fixture
def inherit_fixture():
    return [1, 2]

class TestBase():
    EXPECTED = [1, 2]

    def test_inherit_fixture(self, inherit_fixture):
        assert inherit_fixture == self.EXPECTED

class TestInherit(TestBase):
    EXPECTED = [1, 2, 3, 4]

    @fixture
    def inherit_fixture(self, inherit_fixture):
        return inherit_fixture + [3, 4]

テストでクラスの継承を使い始めるとややこしくなるので、テストケースを継承するようなクラスはそうそう作るべきではないという意見もあります。 とはいえ、自分はこれもケースバイケースであり必要に応じて継承は使うべきだと考えているので、あえてここで紹介しました。

7.まとめ

pytestについて自分の好きな話をなんとかテーマに沿って選抜して、まとめてみました。 正直、半年前まではpytestを含めてテストを書くのは好きではなかったのですが、 pytestのテクニカルな部分に触れるうちに段々と楽しくなっていきやりすぎることも多々ありました。

また、テストを何度も書くうちにテストをしやすいようなコードを書く意識がついて、 自分の設計能力も上がったのは嬉しい誤算でした。

実務的にテストを書くという行為は、納期やリソース、チームのルールなど、非常に多くのパラメータが絡み合っており、 経験から程よいテストをいい感じに書くという、理論や知識よりも経験が求められる世界だと考えています。 なので、Pythonを書く全ての人が、まずはpytestの楽しさに気づいて、テストを書く機会を増やし、 やがてこの世界からレガシーコードが減っていけばと切に切に切に願っています。

レガシーコード改善ガイド

レガシーコード改善ガイド

*1: parametrizeで複数回実行される場合には、その一回の実行を指します

*2:fixture以外にも色々用途があるので、想像以上に早くカオスが生まれます

*3:本稿を書いてて初めて知りました。名案だと思って、趣味のプロジェクトでは結構使ってたんですけどね……。

*4:pluginまで絡んでくるとどうなるのかは未検証ですが、おそらく同様の扱いになるかと思います。pluginの間で循環とかありえるのでしょうか? 気になるところです

*5:テストケースについてはそれ自身を呼び出すこともないので長い名前もOKです

「技術に正しく課金したいがためにアラフォーでも髪を染め続けているよ」というお話。

株式会社ホクソエム常務取締役のタカヤナギ=サンです、主に経営を担当しています。

株式会社ホクソエムの顧客、あるいは同僚から「何で君はアラフォーになっても変な髪色になっとるんじゃい?」という質問を結構いただくんで、 いい加減そのことについての私の考えをポエムにしたいなと思ってこのブログを書いています。

「いや、そんなもん会社のBLOGに書くんじゃねーよ💢」という話があるかもしれないですが、ここは私の保有する会社なので何の問題もない、don't you?

タイトルにある"技術"(テクノロジー)はより正確には”技”(テクネ、スキル)のほうが正しい気もするが、まあここでは問題としないで同義として扱っていきたく存じます。

美容室のビジネスモデル

美容室のビジネスモデルっていうのは比較的シンプルで、月当たりの売上は以下で計算できるわけです。

  • 売上 = 顧客単価 x 顧客人数/月

これが一月の売上になるわけです。 利益は"売上 - 費用"で計算できますが、まあ1人でやってる小さな美容室の全部は固定費(自分の人件費と場所代)だと思えるわけです。 なので、利益を上げるためには売上を上げるしかない(固定はFixedだと思う)という構造になっています*1

美容室の技に正しくお金を払うためには

私は今行っている美容室が好きで、適切に利益を上げ続けてビジネス的に上手く行って欲しいと思っているので、個人的にも利益に貢献していこうという気持ちがあるわけです。

で、利益に貢献しようと思うと、上のビジネスモデルの話にあるように売上に貢献しなければいけないのですが、貢献できる因子としては

  • 顧客単価
  • 顧客人数/月

のどちらかを上げることで貢献するしかないわけですね。

一方、メンズ(おじさん)が美容室に行く頻度なんてまあ普通月に1回くらいなもんで、その回数(顧客人数/月)を増やして大いに利益貢献するってのもありと言えばあり、もう毎週「丸坊主にしてくれ!!!」とオーダーして通いつめればいいんですが、それはちょっと技術を尊重してるとは言えないなと、なのでありよりのなしだよということになり、取れる戦略は”顧客単価の向上”一択となるわけです。

カットのみをオーダーして、高級な鮨🍣屋でやるような お心づけをお渡しする、でもまあ良いと言えばいいんですが、それは技術に課金している感じがしないなと、腕や技をリスペクトしてる感じというよりも、全体的なサービス満足度に対するチップ的な気持ちになってしまうのでなしとしています。

なので、顧客単価を上げようとすると所謂”アップセルな商品”、美容室で言うと通常のカットに加えパーマやらヘッドスパ、カラーをお願いするという選択肢がある中で、私は髪の色を変える(カラーをお願いする)ことで売上(≒利益)に貢献!という方針を取っていると、そういうことです*2。 これが私なりの”正しい技術への課金”ということです。

個人的な意見としては、”自分の見てくれ”に何の興味もない人は、髪の色ぐらいバンバンいじっちゃったらいいんじゃないかなと思っています 。 興味がない事柄だからプロにお任せして好き勝手にやってもらう、技術に課金していく、そういうことです。

まとめ

最近だと Github SponsorsZennのような”すぐ投げ銭できる仕組み”が出来てきてて、 非常に良い時代になってきたんですが、まだ匠の技や技術を持つ個人商店のようなスモールビジネスに対して、どうその技術に対して課金していけばよいのかはよくわかっておらず、試行錯誤しながら応援ヤッテイキをしていこうと思います。

・・・次回も絶対見てくれよな!!!

*1:アップセルとして「独自に仕入れたシャンプーだのの販売」なんかもここでは全部顧客単価に含んでいると思おう、あまりデカくないだろうし。また、変動費の水道光熱費等はまあ無視しておこう。

*2:一時期、パーマの時代もあったが何かの理由で止めてしまった。パーマ液が臭いとかしょうもない理由だったような気もする

ホクソエムのおじさんたちを勝手に踊らせた話

毎週の歯科治療が一段落し, とうとう外に出る理由が一切なくなりました。

ホクソエムサポーターのKAZYです。

6畳の部屋に籠もり続けて健康を維持できるのか不安なこの頃。 運動不足も気になります。

ホクソエムのおじさんたちもきっと同じ悩みを抱えてることでしょう。

ところで最近は静止画を簡単に踊らせてやることができるらしいです。

私は閃きました。

この技術を使ってホクソエムのおじさん達をグリグリ動かす。

そうすればおじさんの運動不足は解消される。

それにより, おじさんたちは気分が良くなる。

私は感謝されご褒美をたんまりもらえる。💰💰💰💰

素晴らしいシナリオです。

天才かもしれない。

今回のアウトプット(忙しい人用)

doukana arekana koukana f:id:KAZYPinkSaurus:20210226134504p:plain

フリー素材です。

ご自由にお持ち帰りください。

今回使う技術の流れの雰囲気なお気持ち

ホクソエムブログって実はテックブログ的なものらしい(NOT ポエム置き場)。

なので今回使う技術のお気持ち程度の解説を記しておくことにする。

この技術が発表された論文の名はLiquid Warping GAN with Attention: A Unified Framework for Human Image Synthesis

2020年のもの。

論文に載っていたFramework OverViewに私がちょびっとコメントを追加画像がこちら。

f:id:KAZYPinkSaurus:20210214193929p:plain
雑of雑な解説

やってることはだいたいこんな感じだ。

やりたいことはSource Image  Is_{i}をReferenceI_r のに姿勢にしたい。

なるべく自然な感じに。

それを実現するために考えられた流れは

1. 画像からHuman Mesh Recovery(HMR)というタスクを行い, 2次元画像から3次元のメッシュ情報を推定してする。
2. 3次元メッシュを考慮した画像間の人物部位の対応マップ的なTをつくる (Transeformation flowと呼んでいる)。
3. Tを使って Is_iI_rの姿勢にした I^{syn}_{t}を生成する。
4. Convolutional Autoencoderライクな  G_{BG}, G_{SID}, G_{TSF}を使って  Is_iの背景画像,  Is_iの人部分のマスク画像と人の画像,  I_rの人部分のマスク画像と人の画像を作ってやる(このとき LWB/AttLWBという機構を使ってG_{SID}からG_{TSF}に情報を送り込んでやっているようだ)。
5.  G_{BG},G_{SID}から生成された背景画像と人の画像を合成して画像\hat{I}_{si}を作る。
6.  G_{BG},G_{TSF}から生成された背景画像と人の画像を合成して画像 syn \hat{I}_{t}を作る (これがお目当てのやつやな)。

上の流れでいい感じの結果を得るために

- \hat{I}_{si}をなるだけI_{si}っぽくしたい気持ち
- syn \hat{I}_{t}を偽物, I_{r}を本物として, それらが見分けがつかないようにした気持ち(ここがGANな要素や)

をあわせて学習する感じ (雑)。

あと一旦3Dメッシュにして3次元的な情報も考えているんだってところがポイントらしいです。 あとあと LWB/AttLWBのところで人物を再構築するために使う特徴量を流し込んでやるところもポイントらしいです。

詳細は論文を読んでほしいです。

こちら↓のサイトに論文, コード, データセットなどなどが置いてあります。 www.impersonator.org

2021年02月現在はComing soonとなっていますが, いいのアプリケーションも開発するプロジェクトもあるようです。 f:id:KAZYPinkSaurus:20210131194257p:plain

おじさんの画像を集める

本題に戻ります。

おじさんたちの画像を集めます。

画像を募ったらなおじさん3名から画像を拝借できました(たぶんフリー素材)。

いい表情。

おじさんを動かす(その1)

この技術,嬉しいことにテストデータを動かしているノートブックが公開されているんです。

脳みそが🐵な私は画像だけ差し替えればなんか動くだろの精神でおじさん達を投入してみます。

colab.research.google.com

f:id:KAZYPinkSaurus:20210214211616p:plain
保存されないぜって言っているし何してもokだろう....

ノートブックをしたに上から下に実行していきます。

f:id:KAZYPinkSaurus:20210214213035p:plain
上からポチポチと実行していく

トランプを踊らせている動画の設定のブロックにたどり着きました。

# This is a specific model name, and it will be used if you do not change it. This is the case of `trump`
model_id = "donald_trump_2"

# the source input information, here \" is escape character of double duote "
src_path = "\"path?=/content/iPERCore/assets/samples/sources/donald_trump_2/00000.PNG,name?=donald_trump_2\""


## the reference input information. There are three reference videos in this case.
# here \" is escape character of double duote "
# ref_path = "\"path?=/content/iPERCore/assets/samples/references/akun_1.mp4," \
#              "name?=akun_2," \
#              "pose_fc?=300\""

ref_path = "\"path?=/content/iPERCore/assets/samples/references/mabaoguo_short.mp4," \
             "name?=mabaoguo_short," \
             "pose_fc?=400\""

# ref_path = "\"path?=/content/iPERCore/assets/samples/references/akun_1.mp4,"  \
#              "name?=akun_2," \
#              "pose_fc?=300|" \
#              "path?=/content/iPERCore/assets/samples/references/mabaoguo_short.mp4," \
#              "name?=mabaoguo_short," \
#              "pose_fc?=400\""

print(ref_path)

!python -m iPERCore.services.run_imitator  \
  --gpu_ids     $gpu_ids       \
  --num_source  $num_source    \
  --image_size  $image_size    \
  --output_dir  $output_dir    \
  --model_id    $model_id      \
  --cfg_path    $cfg_path      \
  --src_path    $src_path      \
  --ref_path    $ref_path

あー, 完全に理解した。

ref_pathで動画, src_pathで画像を指定しているっぽいので, src_pathをおじさんに差し替えちゃえばいいんでしょう?

f:id:KAZYPinkSaurus:20210214214847p:plain
おじさん01をアップロード

src_pathmodel_idをおじさんに差し替えてみました。

# This is a specific model name, and it will be used if you do not change it. This is the case of `trump`
model_id = "ossan01"

# the source input information, here \" is escape character of double duote "
src_path = "\"path?=/content/iPERCore/ossan-01.jpg,name?=ossan01\""


## the reference input information. There are three reference videos in this case.
# here \" is escape character of double duote "
# ref_path = "\"path?=/content/iPERCore/assets/samples/references/akun_1.mp4," \
#              "name?=akun_2," \
#              "pose_fc?=300\""

ref_path = "\"path?=/content/iPERCore/assets/samples/references/mabaoguo_short.mp4," \
             "name?=mabaoguo_short," \
             "pose_fc?=400\""

# ref_path = "\"path?=/content/iPERCore/assets/samples/references/akun_1.mp4,"  \
#              "name?=akun_2," \
#              "pose_fc?=300|" \
#              "path?=/content/iPERCore/assets/samples/references/mabaoguo_short.mp4," \
#              "name?=mabaoguo_short," \
#              "pose_fc?=400\""

print(ref_path)

!python -m iPERCore.services.run_imitator  \
  --gpu_ids     $gpu_ids       \
  --num_source  $num_source    \
  --image_size  $image_size    \
  --output_dir  $output_dir    \
  --model_id    $model_id      \
  --cfg_path    $cfg_path      \
  --src_path    $src_path      \
  --ref_path    $ref_path

あと, 一つ前のセルでnum_source = 2となっていますが, 今回はおじさん画像は1枚なのでnum_source = 1に修正しました。

そしてRun the trump caseのブロックを実行してみます。

待つこと数分。。。

----------------------MetaOutput----------------------
ossan01 imitates mabaoguo_short in ./results/primitives/ossan01/synthesis/imitations/ossan01-mabaoguo_short.mp4
------------------------------------------------------
Step 3: running imitator done.

という表示がでました。 どうやら終わったようです。

youtu.be

おおおおおおおお!!!!! 動いたーーー!

おじさんがおじさんの動画と同じ動きをしております。

なにかの武術を完全にマスターしていますね。

素晴らしい。 強そう。

おじさんを運動せてみる(その2)

今度は別のおじさんを動かしてみましょう。

こちらのおじさん(多分ホクソエムの社長さん)。

先程はサンプルー動画でしたが動画もこちらが指定したものに差し替えてみましょう。

ref_pathを適当に拾ってきた動画に変更します。 あとおじさんも別のおじさんに変えてみます。

youtu.be

元ネタ

社長さんに変な動きをさせている背徳感が堪らない。。。

あと単純に普段していないであろう動きをしているおじさんが面白い。

おじさんを運動せてみる(その3)

調子に乗って最後は激しいダンスとかおじさんにさせてみようと思います。

踊るおじさんがよりリアルになってほしいので今回は正面と背面と2枚の画像を入力してみます。

GO!!!

youtu.be

元ネタ

いやぁ...お腹が痛い。。。

キレッキレじゃないですか。

たまにありえない動きしちゃうあたりが, 手法的にはマイナスなんでしょうがお笑い的には◎。

いやぁ 笑った笑った。

最後に

今回はLiquid Warping GAN with Attention: A Unified Framework for Human Image Synthesisという論文の手法を用いてホクソエムのおじさんの画像を動画と混ぜ合わせて動かしました。

実装がColaboratoryにアップロードされておりとても簡単に動かすことができました。

アウトプットを見るのがとにかく面白かったので, 皆様も身近な人で試してみてはいかがでしょうか。

おじさん達の運動不足を解消したので私はホクソエムからボーナス間違いなしですね 。💴💴💴💴💴

darts-cloneを使って最長一致法で分かち書きしてみる

ホクソエムサポーターの白井です。

呪術廻戦をみて喜久福が食べたくなりました *1

今回は形態素解析について深堀りしてみます。

日本語の自然言語処理において、形態素解析は必ずといっていいほど通る道です。 形態素解析を必要としないSentencePieceのような深層学習向けのtokenizerも出現していますが、品詞単位で分割する形態素解析が重要であることは変わりありません。

そんなこんなで、『実践・自然言語処理シリーズ2 形態素解析の理論と実装』 (以降「形態素解析本」と表記)を読んでいます。 リンク先の目次を見て分かるとおり、基礎の部分から実装まで説明されている本です。

今回は4章で紹介されている darts-clone を使って、精度は粗いが高速で分かち書きができる最長一致法で、どれぐらい分かち書きが可能かを検証します。

事前知識・辞書引き

辞書を使って分かち書きする場合、単語の検索時間が課題となります。 単語を検索するのではなく、部分文字列から辞書に含まれる単語を探索するためです。

すべての開始位置と終了位置を探索し、辞書に含まれるかどうか判定する場合、計算時間がO(n2)かかります。 (実際はハッシュ値を計算するのに文字列長に依存したコストがかかるため、O(n3)です)

一般的に、効率的な単語の探索には 共通接頭辞検索 (Common Prefix Search) による辞書引きが用いられます。 これは文字列を前から探索し、前方の部分文字列と一致する単語を探す方法です。

共通接頭辞検索には トライ (Trie) と呼ばれるデータ構造を用います。 トライは木構造で、文字を1つのノードとし、遷移することで文字列を表現します。

Trie example

Booyabazooka (based on PNG image by Deco). Modifications by Superm401., Public domain, via Wikimedia Commons

ダブル配列 はトライの実装方法のひとつであり、dartsdarts-clone はその実装ライブラリです。 ダブル配列についての詳しい説明は形態素解析本や darts-cloneに記載の参考文献 を参照してください。

darts-cloneを使ってみる

実際にdarts-cloneを動かしてみます。

単語辞書を準備し、darts-clone形式で構築することを目指します。

単語辞書

辞書はmecab用の辞書であるIPA辞書を使います。 Mecabのページからダウンロードし、インストールします。

デフォルトの文字コードは enc-jp ですが、今回は扱いやすいようにutf-8 に変換します。 darts-cloneで辞書を作成するだけであれば、品詞などの情報が不要なので、単語だけ切り取り、ソートしておきます。

$ iconv -f EUC-JP /path/to/mecab-ipadic/Noun.csv|cut -d"," -f1 |LC_ALL=C sort|uniq > /output/path/Noun.csv

iconvを使って文字コードを変換していますが、configureで文字コードを再構築することもできるようです。(文字コード変更)

また、日本語をソートする場合、sortの前に LC_ALL=C のオプションが必要なので注意しましょう。正しくソートされていないと辞書を構築する際エラーになります。

ちなみに複数ファイルをまとめる場合、awkを使ってiconvをかませました。

$ ls Noun.* | awk '{print "iconv -f EUC-JP " $1}'|sh |cut -d"," -f1 |LC_ALL=C sort|uniq >> /output/path/Noun_all.csv

darts-clone

darts-clonegit clone しインストールします。configure ファイルがないので、autoreconfconfigure ファイルを作成します。 このとき、autoreconf automake パッケージをあらかじめインストールしておく必要があります。

Mac OSで試しているため、brew でインストールしていますが、それぞれのOSに合わせて darts-clone をインストールしてください。

$ brew install autoreconf automake
$ autoreconf -i
$ ./configure
$ make

辞書をdarts-clone形式に変換

darts-cloneには、単語一覧をDoubleArrayFile に変換するプログラム mkdarts が付属しているので、それを用います。

$ ./src/mkdarts /path/to/Noun.csv /path/to/ipadic_dict/Noun.dic

keys: 58793
total: 511631
Making double-array: 100% |*******************************************|
size: 275968
total_size: 1103872

名詞だけを変換し、Noun.dic を構築しました。

実際に動くか確認しましょう。 darts を実行すると共通接頭辞検索がインタラクティブに実行できます。

$ ./src/darts /path/to/ipadic_dict/Noun.dic

もも
もも: found, num = 1 3822:6
すもも
すもも: found, num = 2 1996:3 2072:9
ほげ
ほげ: not found

出力ではマッチした要素の個数 (num = 1) と、単語keyに対応するvalueと文字長 (3822:6) が返ってきます。 文字長が単語の長さと一致しないのはバイト文字で表されているためです。 日本語で使う文字の1文字はだいたい3バイトなので、「もも」の結果にある文字長6は2文字 (=もも)にマッチしたという意味になります。

(もちろん3バイトではない文字もあります。UTF-8の文字コード表 - 備忘帳 - オレンジ工房などで一覧で見られるので参考にしてください。)

共通接頭辞の検索では、複数の単語と一致する場合もあります。 例えば、下の実行結果をみると「すもも」の場合、「す」と「すもも」の2つにマッチしているのがわかります。

$ ./src/darts /path/to/ipadic_dict/Noun.dic

す
す: found, num = 1 1996:3
すも
すも: found, num = 1 1996:3
すもも
すもも: found, num = 2 1996:3 2072:9

最長一致法の実装

darts-cloneを使って、最長一致で分かち書きするプログラムを実装します。 最長一致法は最初の文字から共通接頭辞検索し、一番 長く 一致した単語を採用する、ルールベースの分かち書きです。

「すもももももももものうち」の場合、以下のように実行します。

$ ./src/darts Noun.dic
すもももももももものうち 
すもももももももものうち: found, num = 2 1996:3 2072:9
# 「す」「すもも」 → 「すもも」
もももももものうち
もももももものうち: found, num = 1 3822:6
# 「もも」 → 「もも」
もももものうち
# 
# 中略
# 
のうち
のうち: not found
# 一致単語なし → 「の」
うち
うち: found, num = 1 358:6
# 「うち」→ 「うち」

ここで注意したいのは以下の2点です。

  • 「す」「すもも」の2つと一致する場合、長い「すもも」を1単語する
  • 「のうち」のように一致する単語がない場合、一番最初の1文字である「の」を1単語とする

結果「すもも/もも/もも/もも/の/うち」と分割できました。 名詞辞書だけの場合、「も」1文字ではなく「もも(桃)」で分割されていますね。

この例だと最長一致法では全然うまくいかないように見えますが、「スモモも桃も桃のうち」のようにカタカナ・漢字を混ぜると正しく分割されそうなことは感覚的にわかるかと思います。

実際、形態素解析本には以下のように述べられています。

単純なアルゴリズムにもかかわらず,90%以上の分割精度が得られるため,大規模なテキスト集合から大ざっぱな単語頻度を高速に求める処理に向いています.

実践・自然言語処理シリーズ2 形態素解析の理論と実装 P76

では、どれぐらいの精度で分割可能なのか確かめてみます。

python実装

ここからはdarts-cloneのpythonバインディングを利用し、pythonで実装します。 darts-clone-pythonSudachiPyにも使われているdarts-cloneのpythonバインディングです。

形態素解析本のC++実装とSudachiPyの実装を参考にしつつ実装します。

Python 3.8 で動作確認しています。 darts-clone-pythonは v0.9.0 です。

import sys
import dartsclone


class DartsDict:
    def __init__(self, filename):
        self.trie = self.load_darts(filename)

    @staticmethod
    def load_darts(filename):
        darts = dartsclone.DoubleArray()
        darts.open(filename)
        return darts

    def longest_match(self, line: str):
        line_b = line.encode("utf-8")
        begin_str = 0
        begin = 0
        end = len(line_b)
        while begin < end:
            longest_length = 0
            key = line_b[begin:]
            result = self.trie.common_prefix_search(key, length=len(key))
            for (word_id, length) in result:
                longest_length = max(longest_length, length)

            if longest_length == 0:
                # 一致する単語がなかった場合
                longest_length = len(line[begin_str].encode("utf-8"))

            word = line_b[begin:begin+longest_length].decode()
            yield word

            begin += longest_length
            begin_str += len(word)


def sample():
    darts = DartsDict("/path/to/ipadic_dict/Noun.dic")
    for w in darts.longest_match("すもももももももものうち"):
        print(w)
    print("EOS")


def main(dic_file, text_file):
    darts = DartsDict(dic_file)
    with open(text_file, "r")as f:
        for line in f:
            words = []
            for w in darts.longest_match(line.strip()):
                words.append(w)
            print(" ".join(words))


if __name__ == '__main__':
    args = sys.argv[1:]
    if len(args) < 2:
        print("Usage: python main.py dic_file text_file")
    else:
        main(args[0], args[1])

検証

実装したところで、最長一致法でどこまで正確に分かち書きできるのか検証していきます。

今回は livedoor ニュースコーパス のテキストを使ってmecabの分かち書きと比較していきます。

データ

livedoor ニュースコーパスからダウンロードできるデータ ldcc-20140209.tar.gz の記事から、トピックごとそれぞれ1つずつ、合計9つの文書を使います。

具体的にはディレクトリごとsortして一番上のテキストデータを使いました。 ただし、1~2行目のURL・日付は取り除いてます。

辞書はipadic (mecab-ipadic-2.7.0-20070801) のcsvを使って4種類作成しました。 名詞・動詞を選んだのは単語数が多く、重要度が高いと予想したためです。

名前 説明 単語数
Noun 名詞・一般 (Noun.csv) だけ 58,793
NounAll 全ての名詞 (Noun*.csvで指定) 197,489
Verb 動詞 (Verb.csv)だけ 101,751
NounVerb NounAll + Verb 296,935

mecabのIPA辞書を使った -Owakati の出力結果を正解データとし、正解データと一致したかどうかで評価します。 MevAL単語境界判定のエラー分析 をベースに評価コードを作成しました。

def eval_line(gold, pred):
    gold_cnt, pred_cnt = 0, 0
    idx = 0
    tp, fp, fn = 0, 0, 0
    for g in gold:
        gold_cnt += len(g)
        if g == pred[idx]:
            # print("true ", g, pred[idx])
            tp += 1
            pred_cnt += len(pred[idx])
            idx += 1
        else:
            if gold_cnt < pred_cnt + len(pred[idx]):
                fn += 1
                # print("fn  ", g, pred[idx])
            else:
                tmp = []
                while gold_cnt > pred_cnt:
                    tmp.append(pred[idx])
                    pred_cnt += len(pred[idx])
                    idx += 1
                    fp += 1
                # print("fp  ", g, " ".join(tmp))
    return tp, fp, fn

結果と考察

文書ごとのF値は以下の表のとおりです。 基本的に単語数が多いほどF値は高い結家となりました。

Noun NounAll Verb NounVerb 行数
dokujo-tsushin-4778030.txt 0.593 0.725 0.556 0.766 25
it-life-hack-6292880.txt 0.567 0.680 0.449 0.725 32
kaden-channel-5774093.txt 0.659 0.807 0.574 0.849 19
livedoor-homme-4568088.txt 0.693 0.847 0.565 0.878 18
movie-enter-5840081.txt 0.612 0.702 0.642 0.788 23
peachy-4289213.txt 0.638 0.765 0.534 0.795 16
smax-6507397.txt 0.485 0.536 0.425 0.555 79
sports-watch-4597641.txt 0.614 0.780 0.562 0.841 10
topic-news-5903225.txt 0.517 0.607 0.463 0.658 58

一番F値が良いのNounVerbのprecisionとrecallをみてみるとprecisionが低いことがわかります。 FalsePositive(正解にはない境界があると予測)、つまり正解より細かく分割している数が多いということです。

precision recall F1
dokujo-tsushin-4778030.txt 0.683 0.872 0.766
it-life-hack-6292880.txt 0.585 0.954 0.725
kaden-channel-5774093.txt 0.770 0.946 0.849
livedoor-homme-4568088.txt 0.802 0.970 0.878
movie-enter-5840081.txt 0.695 0.909 0.788
peachy-4289213.txt 0.704 0.912 0.795
smax-6507397.txt 0.398 0.919 0.555
sports-watch-4597641.txt 0.765 0.934 0.841
topic-news-5903225.txt 0.507 0.937 0.658

具体的に分割結果をみてみます。

まず、以下のように名詞、特に漢字が多い文においてはほぼ同じ分割になりました。

今月 8 日 、 都内 ホテル で は 、 総合 格闘 家 ・ 吉田 秀彦 の 引退 試合 興行 「 ASTRA 」 の 開催 が 発表 さ れ た 。

今月 8 日 、 都内 ホテル で は 、 総合 格闘 家 ・ 吉田 秀彦 の 引退 試合 興行 「 A S T R A 」 の 開催 が 発表 され た 。 上がmecab、下がNounVerbの分割 sports-watch-4597641.txt

一方、辞書の問題として、「Twitter」や「TV」のような英単語が「T w i t t e r」「T V」と1文字ごと分割されてしまう課題があります。 これは英単語を辞書に登録することで解消されるはずです。

画面 下 に は Facebook 、 Twitter 、 SHARE ( Facebook 、 Twitter 、 メール 、 SMS ) で の 共有 、

画面 下 に は F a c e b o o k 、 T w i t t e r 、 S H A R E ( F a c e b o o k 、 T w i t t e r 、 メール 、 S M S ) で の 共有 、 上がmecab、下がNounVerbの分割 smax-6507397.txt

しかしながら、「すももも〜」と同様、ひらがなの多い部分については最長一致というルール上、mecabと同じ結果を出力することができないです。

その 過半数 は 毎年 5 , 000 円 程度 かかる 更新 費用 や その 手続き について 不満 を 持っ て いる 。(中略)性能 面 で 劣る の で は という 不安 から 導入 を 控え て いる という 状況 に ある 。

そ の 過半数 は 毎年 5 , 0 0 0 円 程度 かかる 更新 費用 や そ の 手続き につい て 不満 を 持っ てい る 。(中略)性能 面 で 劣る の で はと いう 不安 から 導入 を 控え てい る とい う 状況 に ある 。 上がmecab、下がNounVerbの分割 it-life-hack-6292880.txt

上の例ではNounVerbが「持っている」「控えている」について、「持っ/てい/る」「控え/てい/る」と左に最長であるように分割されます。 ですが、正しい分割は以下のように「て/いる」であり、「いる」が動詞であるように分割したい部分です。

持っ    動詞,自立,*,*,五段・タ行,連用タ接続,持つ,モッ,モッ
て      助詞,接続助詞,*,*,*,*,て,テ,テ
いる    動詞,非自立,*,*,一段,基本形,いる,イル,イル

おわりに

最長一致法を実装しながら、ダブル配列ライブラリdarts-cloneの使い方を解説しました。

ナイーブな最長一致法は高速かつ、ある程度は使える実装ですが、分割には課題点もあります。

mecabの実装のように、最適化アルゴリズムを使うべきだというお気持ちがちょっと分かった気がします。

また、今回は細かい部分は割愛して darts.open(filename) で辞書を読み込みましたが、形態素解析本では全部をメモリに載せず メモリマップトファイル (pythonでは mmap) を使って最適化する実装が提案されています。

実際、IPA辞書の全単語でdarts-cloneの辞書を構築してメモリに乗せようとするとsegmatation faultします……。 この記事はあくまで「使ってみた」系記事ですので、ご了承ください。

参考資料

mecabとdarts関連

おまけ資料:SudachiPyで最長一致法

前述のように、SudachiPyはdarts-cloneで辞書引きしているので、実装されたクラスをうまく活用すれば最長一致法での分かち書きが実装できます。

具体的には BinaryDictionary クラスで辞書の設定をしているので、そこから単語辞書 (DoubleArrayLexiconのインスタンス)だけを抜き出して使います。

ちなみにDoubleArrayLexiconでは mmap を使った辞書の読み込みを行ってます。

from pathlib import Path
from sudachipy.dictionarylib.binarydictionary import BinaryDictionary

# 辞書のパス
system_dic = (Path(import_module('sudachidict_core').__file__).parent / 'resources' / 'system.dic') 

dict_ = BinaryDictionary.from_system_dictionary(system_dic)
lexicon = dict_.lexicon


def longest_match(line: str):
  line_b = line.encode("utf-8")
  begin_str = 0
  begin = 0
  end = len(line_b)
  while begin < end:
    longest_length = 0
    longest_word_id = None
    for (word_id, length) in lexicon.lookup(line_b, begin):
      if longest_length < length:
        longest_length = length
        longest_word_id = word_id


    if longest_length==0:
      # print(line[begin_str])
      longest_length = len(line[begin_str].encode("utf-8"))

    word = line_b[begin:longest_length].decode()
    yield word

    begin = longest_length
    begin_str += len(word)

SudachiPyは v0.4.9 で確認しています。

SudachiPyの辞書であるSudachiDict の語彙数が多いため、結構正確に分割できると思います。 当たり前ですがSudachiPyより高速です。

*1:宮城にしかないと思っていたのですが、ググったら関東にも店舗を出店しているみたいで、正直驚いています。