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

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

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

はじめに

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

掲題の件、現在、某社さんと”機械学習における評価指標とビジネスの関係、および宇宙の全て”というタイトルの書籍を書いているのですが、 本記事のタイトルにあるような考え方については、論文・書籍などを数多く調査しても未だお目にかかることができず、これをいきなり書籍にして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:宮城にしかないと思っていたのですが、ググったら関東にも店舗を出店しているみたいで、正直驚いています。

EDINET APIって知ってる? ~有価証券報告書をもっと楽にダウンロードする話~

はじめに

こんにちは, ホクソエムサポーターのKAZYです。

最近はペンギンに興味があります🐧。

世界最大のペンギンであるコウテイペンギンを日本で見るならば名古屋港水族館 (愛知) かアドベンチャーワールド (和歌山) らしいです。

ところで, 平成31年3月17日からEDINETに提出された書類をAPIで取得できるようになったことをご存知でしょうか?

だからなんなの?っていう方聞いてください。

もうブラウザポチポチやらなくても有価証券報告書ダウンロードできるんですよっ!!!!

「退屈なことはPythonにやらせよう」マンになる時が来たのです。

今回はEDINET APIで有価証券報告書を保存するための最低限の知識任意の日付の有価証券報告書をダウンロードするPythonプログラムを紹介します。

読んだら幸せになりそうな方

  • 提出日を指定した有価証券報告書のダウンロードしたい方 (e.g. 1年分全部欲しいぜ!!!)
  • ぼんやりEDINET APIで何ができるか知っておきたい方

読んでもあんまり幸せにならない方

  • 企業名を指定した有価証券報告書のダウンロード (e.g. マクドナルドの有価証券報告書が5年分ほしいぜ!!!!)

↓ここで紹介しているぜ↓ blog.hoxo-m.com

  • 全然暇じゃないし, 正確of正確な情報を取りに行きたい
    • こちらのAPIの仕様書に使い方は全て書いてある, Have a nice day👋

どうでもいいからはよXBRLファイルをDLさせろ💢って方

KAZYの拙いスクリプトをどうぞ

poetry使える人

git clone https://github.com/KAZYPinkSaurus/disclosure-crowler.git
cd disclosure-crowler
# ライブラリのインストール
poetry install

#オプションを表示
poetry run python -m disclosure.main --help

# 2020/09/04から2020/10/04の有価証券報告書をダウンロードしてxbrlファイルを抽出
poetry run python -m disclosure.main --from 2020-09-04 --to 2020-10-04 -x
ls output/*

poetry使えない人

git clone https://github.com/KAZYPinkSaurus/disclosure-crowler.git
cd disclosure-crowler
pip install requests==2.24.0 python-dateutil==2.8.1 loguru==0.5.3 click==7.1.2

#オプションを表示
poetry run python -m disclosure.main --help
# 2020/09/04から2020/10/04の有価証券報告書をダウンロードしてxbrlファイルを抽出
python -m disclosure.main --from 2020-09-04 --to 2020-10-04 -x

KAZY< ばいばい 👋

🤖 < ありがとうございました

APIって...?

According to Wikipedia, APIとは

アプリケーションプログラミングインタフェース(API、英: Application Programming Interface)とは、広義ではソフトウェアコンポーネント同士が互いに情報をやりとりするのに使用するインタフェースの仕様である。 前述のとおりAPIは各種システム/サービスがそのシステム/サービスを利用するアプリケーションに対して公開するインタフェースである。

ということのようです。 インターフェースと仕様提供するから退屈なことはPythonにやらせるんだぞっ!!と言う声が聞こえてくる気がします🦻。

EDINET APIについて

EDINET APIは以下の2つのAPIの総称です。

  1. 提出された書類を把握するためのAPI
    • 書類のメタ情報を教えてくれる
  2. 提出された書類を取得するためのAPI
    • 実際に書類を手に入れられる

取得できる期間

  • EDINET APIで取得できる書類は直近5年分です。*1

特に注意してほしいこと

利用規約の第5条(禁止事項)には

1.利用者は、以下に掲げる行為を行ってはならないものとします。
(1) 本機能の健全な運営を害する一切の行為
(2) 短時間における大量のアクセスその他の本機能の運用に支障を与える行為

とあります。

(2)の短時間の大量アクセスは起こしやすいので特に注意しましょう。

とはどういう意味合いなのでしょう。まったくわかりません。

〜〜〜以下、KAZYの脳内のやり取り〜〜〜

KAZY < どれくらいアクセスしたら大量アクセスなの?

EDINET API< う~ん, とにかく運用に支障を与えるくらいのアクセスはやめてくれってことや!

KAZY.o0(運用次第やな....)

EDINET API< 無限ループするスクリプト書いてアクセスし続けるとかやめてくれよな!

KAZY< OK

EDINET API< アクセスするスクリプトをたくさん並列して動かすとかもやめてくれよな!!

KAZY< 理解

有価証券報告書のXBRLファイルをダウンロード(コマンドライン編)

きっと理解しやすい方がいると思うのでコマンドラインからの取得方法を見ていきましょう。

目標

  • 2020/01/07に提出された有価証券報告書を1つ取得する

流れ

  1. 日付をメタファイルを取得
  2. メタファイルから書類ID(docID)を取得
  3. docIDを指定して有価証券報告書をダウンロード

メタファイル取得(2020/01/07)

curl "https://disclosure.edinet-fsa.go.jp/api/v1/documents.json?date=2020-01-07&type=2"

↓レスポンス

{
    "metadata":
        {
            "title": "提出された書類を把握するためのAPI",
            "parameter":
                {
                    "date": "2020-01-07",
                    "type": "2"
                },
            "resultset":
                {
                    "count": 179
                },
            "processDateTime": "2020-10-04 00:00",
            "status": "200",
            "message": "OK"
        },
    "results": [
        {
            "seqNumber": 1,
            "docID": "S100HNA6",
            "edinetCode": "E08957",
            "secCode": null,
            "JCN": "4010401049128",
            "filerName": "三井住友DSアセットマネジメント株式会社",
            "fundCode": "G12668",
            "ordinanceCode": "030",
            "formCode": "07A000",
            "docTypeCode": "120",
            "periodStart": "2018-10-11",
            "periodEnd": "2019-10-10",
            "submitDateTime": "2020-01-07 09:01",
            "docDescription": "有価証券報告書(内国投資信託受益証券)-第2期(平成30年10月11日-令和1年10月10日)",
            "issuerEdinetCode": null,
            "subjectEdinetCode": null,
            "subsidiaryEdinetCode": null,
            "currentReportReason": null,
            "parentDocID": null,
            "opeDateTime": null,
            "withdrawalStatus": "0",
            "docInfoEditStatus": "0",
            "disclosureStatus": "0",
            "xbrlFlag": "1",
            "pdfFlag": "1",
            "attachDocFlag": "1",
            "englishDocFlag": "0"
        },
        {
            "seqNumber": 2,
            "docID": "S100HOID",
            ︙続く

メタファイルから書類ID(docID)を取得

文書IDは有価証券報告書のダウンロードのために必要です。

先程のレスポンスをresults->docIDと辿ります。

︙省略
            "docID": "S100HNA6",
︙省略       

一番上のdocIDにはS100HNA6という要素が入っていますね。

これです。

有価証券報告書を取得

XBRLファイルが圧縮されたzipファイルを取得します。

書類IDS100HNA6のファイルをダウンロードしてみましょう。

エンドポイントは https://disclosure.edinet-fsa.go.jp/api/v1/documents/ドキュメントのID って感じで提供されています。

そしてパラメータをtype=1とすると書類が取得できます。*2

ダウンロードしましょう (with curlコマンド)!!!

## -o は標準週力じゃなくてファイルに書き出してくれるオプションだよ
curl -o S100HNA6.zip "https://disclosure.edinet-fsa.go.jp/api/v1/documents/S100HNA6?type=1"
🐦.o0(たーみなる)% curl -o S100HNA6.zip "https://disclosure.edinet-fsa.go.jp/api/v1/documents/S100HNA6?type=1"
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  670k  100  670k    0     0   263k      0  0:00:02  0:00:02 --:--:--  262k

🐦.o0(たーみなる)% ls -lh S100HNA6.zip
-rw-r--r--  1 kazy  staff   670K 10 10 11:26 S100HNA6.zip

670KBのファイルがダウンロードされました。 やった!

有価証券報告書のXBRLファイルをダウンロード(Python編)

次にダウンロードするPythonスクリプトを書いてみます。

🤖 < requests 使うだけでしょ? そんなことでブログ膨らますなよ

KAZY< ....

目標(コマンドライン編と同じ)

  • 2020/01/07に提出された有価証券報告書を1つ取得する

流れ(コマンドライン編と同じ)

  1. 日付をメタファイルを取得
  2. メタファイルから書類ID(docID)を取得
  3. docIDを指定して有価証券報告書をダウンロード

メタファイル取得(2020/01/07)

>>> import requests
>>> 日付 = "2020-01-07"
>>> メタファイルのタイプ = 2
>>> META_URL = f"https://disclosure.edinet-fsa.go.jp/api/v1/documents.json?date={日付}&type={メタファイルのタイプ}"
>>> text = requests.get(META_URL).text
>>> print(text)
{
    "metadata":
        {
            "title": "提出された書類を把握するためのAPI",
            "parameter":
                {
                ︙省略

取得できました。

メタファイルから書類ID(docID)を取得

返ってきたjson形式のテキストを辞書として読み込みましょう。

# さっきの続きだよ
>>> import json
>>> meta_dict = json.loads(text)
>>> meta_dict.keys()
dict_keys(['metadata', 'results'])

読み込めてそうですね。 1つ目の書類の情報を出力してみましょう。

>>> meta_dict['results'][0]
{'seqNumber': 1, 'docID': 'S100HNA6', 'edinetCode': 'E08957', 'secCode': None, 'JCN': '4010401049128', 'filerName': '三井住友DSアセットマネジメント株式会社', 'fundCode': 'G12668', 'ordinanceCode': '030', 'formCode': '07A000', 'docTypeCode': '120', 'periodStart': '2018-10-11', 'periodEnd': '2019-10-10', 'submitDateTime': '2020-01-07 09:01', 'docDescription': '有価証券報告書(内国投資信託受益証券)-第2期(平成30年10月11日-令和1年10月10日)', 'issuerEdinetCode': None, 'subjectEdinetCode': None, 'subsidiaryEdinetCode': None, 'currentReportReason': None, 'parentDocID': None, 'opeDateTime': None, 'withdrawalStatus': '0', 'docInfoEditStatus': '0', 'disclosureStatus': '0', 'xbrlFlag': '1', 'pdfFlag': '1', 'attachDocFlag': '1', 'englishDocFlag': '0'}

1つ目の書類のdocIDを出力してみましょう。

>>> 書類のID = meta_dict['results'][0]['docID']
>>> 書類のID
'S100HNA6'
# ついでに名前も表示してみた
>>> meta_dict['results'][0]['filerName']
'三井住友DSアセットマネジメント株式会社'

取得できました。

有価証券報告書を取得

XBRLファイルが圧縮されたzipファイルを取得します。

>>> 書類のタイプ = 1
>>> 書類取得のURL = f"https://disclosure.edinet-fsa.go.jp/api/v1/documents/{書類のID}?type={書類のタイプ}"
>>> response = requests.get(書類取得のURL)
>>> response
<Response [200]>

ステータスコードが成功のレスポンスなので取れてるっぽいですね。

保存してみましょう。

with open(f"{書類のID}_python.zip", 'wb') as f:
    f.write(response.content)
🐦.o0(たーみなる)% ls -lh S100HNA6_python.zip
-rw-r--r--  1 kazy  staff   670K 10 11 13:32 S100HNA6_python.zip

取得できましたね。

パチパチ👏

................さて,

ここまでネット上で説明されまくっている話をほぼそのまま説明しました。

ここからは適当なワークをしてちっとは意味がある記事風に仕上げていきたいと思う。💪💪💪💪💪💪💪💪💪💪💪

(適当なワーク1)有価証券報告書が提出されるのが多いのは何月?

結論

  • 6月
    • なんで?: 3月決算の企業が多い + 有価証券報告書の提出は決算後3ヶ月以内と義務付けられている

検証物語

企業によって有価証券報告書を提出する日付は様々です。

提出が多い月, 少ない月などはあるのでしょうか?

本日紹介したEDINET APIを使って集計してみます。

〜〜〜以下に集計の際にKAZYの脳内で行われたやり取り〜〜〜

KAZY< 魔法使いさん, 集計して!!

🧙‍♂️< えいやっ! ぼんっ(魔法の音)

KAZY< 1年分ないやんけ(2020年10月執筆時)

🧙‍♂️< 2019ね〜ん, えいやっ! ぼぼんっ(魔法の音)

KAZY< もう一声!!!

🧙‍♂️< 2018ね〜ん, えいやっ! ぼぼぼんっ(魔法の音)

KAZY< 6月提出が圧倒的に多いな

🧙‍♂️< 決算月が3月の企業が多いからじゃな

KAZY< 3ヶ月ずれとるやんけ

🤖 < 有価証券報告書は各事業年度終了後、3か月以内の金融庁への提出が義務づけられているんやでぇ

KAZY< なるほど

(適当なワーク2)有価証券報告書が提出されるのが多いのはいつ?

結論

  • 金曜日
    • (なんで?) しらん, 締切になりがちなんちゃう?

      検証物語

以下に集計の際にKAZYの脳内で行われたやり取り。

KAZY< 魔法使いさん, こんどは曜日で集計して!!

KAZY< 3年分頼むわ!!!

🧙‍♂️< そいやっ! ぽんっぽんっぽんっ(魔法の音)

KAZY< 金曜日が多いな

KAZY< 締切に設定されがちなんやろな

KAZY< 土日に提出はしないんだな

(おまけ)期間を指定して有価証券報告書のXBRLファイルをダウンロードして展開してXBRLファイルのみ抽出するしてあとは削除しちゃうプログラムのスクリプト

どぞ。

github.com

↓使い方(再掲)↓

poetry使える人

git clone https://github.com/KAZYPinkSaurus/disclosure-crowler.git
cd disclosure-crowler
# ライブラリのインストール
poetry install

#オプションを表示
poetry run python -m disclosure.main --help

# 2020/09/04から2020/10/04の有価証券報告書をダウンロードしてxbrlファイルを抽出
poetry run python -m disclosure.main --from 2020-09-04 --to 2020-10-04 -x
ls output/*

poetry使えない人

git clone https://github.com/KAZYPinkSaurus/disclosure-crowler.git
cd disclosure-crowler
pip install requests==2.24.0 python-dateutil==2.8.1 loguru==0.5.3 click==7.1.2

#オプションを表示
poetry run python -m disclosure.main --help
# 2020/09/04から2020/10/04の有価証券報告書をダウンロードしてxbrlファイルを抽出
python -m disclosure.main --from 2020-09-04 --to 2020-10-04 -x

おわりに

EDINET APIを使って簡単にXBRLファイルをダウンロードできましたね。 XBRLファイルをダウンロードしたら次にやりたいのが構文解析してテキストマイニングじゃあありませんか? けど...難しいんでしょ? って方はこちらを読んでみるといいかもしれませんよ。

blog.hoxo-m.com

それでは。

Appendix

書類一覧APIで変えたくなるパラメータ

パラメータ名 説明
date YYYY-MM-DD ファイル日付を指定
type 1 メタデータのみを取得(typeが指定ないとこちらになる)
type 2 提出書類一覧及びメタデータを取得

書類取得APIで変えたくなるパラメータ

パラメータ名 説明 ファイル形式
type 1 提出本文書及び監査報告書を取得 ZIP
type 2 PDFファイルを取得 PDF
type 3 代替書面・添付文書を取得 ZIP
type 4 英文ファイルを取得 ZIP

参考

*1:ブラウザからダウンロードも同様

*2:ちなみに, typeを2にすると有価証券報告書のpdfファイルがダウンロードできます。 他はAppendixを参照してみてください。

有価証券報告テキストマイニング入門

はじめに

こんにちは, ホクソエムサポーターのKAZYです。 先日猫カフェデビューをして, 猫アレルギーであることがわかりました🐈。 次はフクロウカフェに挑戦してみようかなと思っています🦉。

ところで皆様, 有価証券報告書は読んでますか? 私は読んでいません。 読めません。 眺めていると眠くなります💤。

私は眠くなるんですが, 有価証券報告書ってテキストマイニングするのに向いているんです。企業の事業や財務情報が詳細に書かれています。 XBRL形式で構造化されています。 数千社分のテキストが手に入ります。 おまけに無料です。

どうです?興味湧いてきませんか?

本記事ではPythonを使って有価証券報告書をテキストマイニングする方法を紹介します。 有価証券報告書をダウンロードするところからご紹介するのでご安心を。

こんな方が見たら役に立つかも

  • 企業分析をプログラミングでやりたいが何していいか何もわからん方
  • プログラミングできるけど有価証券報告書何もわからん方
  • 自然言語処理をするのに丁度いいテキストを探している方

やること

  • 有価証券報告書についての説明
  • 有価証券報告書のダウンロード方法紹介
  • XBRLファイルについての説明
  • Pythonで有価証券報告書からテキスト抽出
  • 抽出したテキストから簡単なテキストマイニング

有価証券報告書とは

有価証券報告書, 通称「有報」👽🛸は企業の状況を外部へ開示するための資料です。 企業が自ら毎年作成しています。 企業分析をする際などによく利用されます。

提出企業

日本にうん100万社ある企業すべてが有報は提出をしている訳ではありません。

以下の条件を満たす企業だけが各事業年度毎に国へ提出を義務付けられています。

  • 金融商品取引所(証券取引所)に株式公開している会社
  • 店頭登録している株式の発行会社
  • 有価証券届出書提出会社
  • 過去5年間において、事業年度末日時点の株券もしくは優先出資証券の保有者数が1000人以上となったことがある会社(ただし、資本金5億円未満の会社を除く)

雰囲気としては上場企業+α が提出している感じです。 *1

記載内容

企業の沿革, 事業内容, 財務状況, 研究開発状況, 抱える課題, 経営方針, キャッシュフロー, 役員状況... などの情報がとても詳細に記述されています。

情報量

PDFで100ページを越えるような報告書が多いです。 例えば日本マクドナルドホールディングス株式会社の第49期の有価証券報告94ページです。 (100ページ越えてない...)

取得方法

有価証券報告の取得方法は大きく分けて以下の2つあります。

  1. ブラウザからダウンロード
  2. APIからダウンロード

今回は1.のブラウザからダウンロードする方法を紹介します。

有価証券報告書は金融庁が運営するEDINETと呼ばれるWEBサイトから自由にダウンロードができます。

皆さんご存知の日本を代表する企業, 株式会社ホクソエムの有価証券を検索してダウンロードしてみます。

該当するデータが存在しません。

と出てしまいました。

株式会社ホクソエムは有報の提出が義務付けられている会社ではありませんでした。

つぎに私が学生時代にお世話になった日本マクドナルドホールディングス株式会社を検索してみます🍔。
4件ヒットしました。 そのなかに平成27年~令和2年の有価証券報告書がありますね。 *2

XBRLと書かれたアイコンをクリックして見ましょう。 zipで圧縮されたファイルがダウンロードできます(PDFをクリックするとpdfの書類がダウンロードできます)。

ダウンロードしたzipファイルの中身

ダウンロードしたデータを展開すると以下のようなファイルが入っています。

Xbrl_Search_20200909_221659
├── S100ICBO
│   └── XBRL
│       ├── AuditDoc
│       │   ├── jpaud-aai-cc-001_E03366-000_2019-12-31_01_2020-03-30.xbrl
│       │   ├── jpaud-aai-cc-001_E03366-000_2019-12-31_01_2020-03-30.xsd
│       │   ├── jpaud-aai-cc-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│       │   ├── jpaud-aai-cc-001_E03366-000_2019-12-31_01_2020-03-30_pre.xml
│       │   ├── jpaud-aar-cn-001_E03366-000_2019-12-31_01_2020-03-30.xbrl
│       │   ├── jpaud-aar-cn-001_E03366-000_2019-12-31_01_2020-03-30.xsd
│       │   ├── jpaud-aar-cn-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│       │   ├── jpaud-aar-cn-001_E03366-000_2019-12-31_01_2020-03-30_pre.xml
│       │   └── manifest_AuditDoc.xml
│       └── PublicDoc
│           ├── 0000000_header_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0101010_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0102010_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0103010_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0104010_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0105010_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0105020_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0106010_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0107010_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── 0200010_honbun_jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_ixbrl.htm
│           ├── images
│           │   ├── 0101010_001.png
│           │   ├── 0101010_002.png
│           │   └── 0104010_001.png
│           ├── jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30.xbrl ←これ
│           ├── jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30.xsd
│           ├── jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_cal.xml
│           ├── jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_def.xml
│           ├── jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_lab-en.xml
│           ├── jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_lab.xml
│           ├── jpcrp030000-asr-001_E03366-000_2019-12-31_01_2020-03-30_pre.xml
│           └── manifest_PublicDoc.xml
└── XbrlSearchDlInfo.csv

XML, HTML, CSV, XBRLファイルが入っていますね。

今回テキストマイニングに使用するのはxbrlの拡張子がついたファイルです。

XBRLファイルはAuditDoc,PublicDocの2つのディレクトリ内に入っています。 それぞれ有価証券報告書内の監査に関する報告, それ以外の報告が記載されています。

XBRLファイルとは

XBRLファイルについて, テキストマイニングに必要最低限の説明します。 XBRLファイルとはXMLをベース に事業報告用に拡張したマークアップ言語です。*3

実際にXBRLファイルを覗いてみると,

<?xml version="1.0" encoding="UTF-8"?>
<xbrli:xbrl>
︙~中略~
</xbrli:xbrl>

と記述されています。 一行目に思いっきりxmlと書いてあることが確認できると思います。 そういうことです。

また, 有報に記述する基本的な名前空間とタグは定義されており, タクソノミ要素リストで確認できます(リンクは2020年版)。

タグが統一されているので様々な企業が提出するXBRLファイルを一度にプログラムで処理しやすいのです。 素敵です。

具体例として, 有報表紙の企業名部分の名前空間とタグはそれぞれjpcrp_cor,CompanyNameCoverPageで書きましょうと決められております。

実際にマクドナルドとモスバーガーのXBRLファイル内の対象部分を見ると,

<jpcrp_cor:CompanyNameCoverPage contextRef="FilingDateInstant">日本マクドナルドホールディングス株式会社</jpcrp_cor:CompanyNameCoverPage>

<jpcrp_cor:CompanyNameCoverPage contextRef="FilingDateInstant">株式会社モスフードサービス</jpcrp_cor:CompanyNameCoverPage>

とどちらも同じタグの中に会社名が囲まれていますね。

これでどんな情報がどんなタグで表現されているかがわかるので早速プログラムを書いてテキスト抽出していきましょう。

XBRLファイルについてもっと詳しく知りたいという方用にオススメ記事リンク貼っておくので読んでみてください。

👇👇👇👇👇👇👇👇👇👇👇👇

note.com

note.com

note.com

Pythonを使って任意の項目を抽出する

次に, Pythonを用いてXBRLファイルから任意の項目のテキストを抽出します。 今回はlxmlというライブラリを使います。 *4

【会社名】項目の抽出

会社名をXBRLファイルから抽出してみます。 会社名は有価証券報告書の表紙に書いてあります。 以下のスクリプトで抽出きます。

from lxml import etree
mcdonalds_xbrl = "./Xbrl_Search_20200914_000607/S100IW0P/XBRL/PublicDoc/jpcrp030000-asr-001_E02675-000_2020-03-31_01_2020-06-25.xbrl"

# xbrlファイルを指定してパース
tree = etree.parse(mosuburger_xbrl)
root = tree.getroot()
# 名前空間とタグを指定して検索
company_name = root.find("jpcrp_cor:CompanyNameCoverPage",root.nsmap).text

>> 日本マクドナルドホールディングス株式会社

簡単ですね。 XBRLファイルをモスフードサービスのものに変更すると,

from lxml import etree
mosuburger_xbrl = "./Xbrl_Search_20200914_000607/S100IW0P/XBRL/PublicDoc/jpcrp030000-asr-001_E02675-000_2020-03-31_01_2020-06-25.xbrl"

# xbrlファイルを指定してパース
tree = etree.parse(mosuburger_xbrl)
root = tree.getroot()
# 名前空間とタグを指定して検索
company_name = root.find("jpcrp_cor:CompanyNameCoverPage",root.nsmap).text

>> 株式会社モスフードサービス

同じコードで会社名が取り出せました。

【事業の内容】項目を抽出

次に事業の内容が記載された項目の抽出を行ってみます(上図)。

タクソノミ要素リストから事業の内容のタグを調べます。jpcrp_cor:DescriptionOfBusinessTextBlockのタグでできると書いてあります。 検索するタグを変更してスクリプトを実行します。

from lxml import etree
mcdonalds_xbrl = "./Xbrl_Search_20200914_000607/S100IW0P/XBRL/PublicDoc/jpcrp030000-asr-001_E02675-000_2020-03-31_01_2020-06-25.xbrl"

# xbrlファイルを指定してパース
tree = etree.parse(mosuburger_xbrl)
root = tree.getroot()
# 名前空間とタグを指定して検索
bussiness_block = root.find("jpcrp_cor:DescriptionOfBusinessTextBlock",root.nsmap).text

>> '\n<h3>3【事業の内容】</h3>\n<p style="margin-left: 24px; text-align: left">\n<span style="font-family: &apos;MS Mincho&apos;; font-size: 12px">\u3000当社グループの事業はハンバーガーレストラン事業単一であるため、セグメント情報に関連付けた記載を行っていません。</span>\n</p>\n<p style="margin-left: 24px; text-align: left">\n<span style="font-family: &apos;MS Mincho&apos;; font-size: 12px">(当社の事業内容)</span>\n</p>\n<p style="margin-left: 24px; text-align: left">\n<span style="font-family: &apos;MS Mincho&apos;; font-size: 12px">\u3000当社は、日本マクドナルド株式会社の持株会社として、グループ企業の連結経営戦略の策定業務と実行業務及び不動産賃貸業務を主たる事業としております。</span>\n</p>\n<p style="margin-left: 24px; text-align: left">\n<span style="font-family: &apos;MS Mincho&apos;; font-size: 12px">\u3000なお、当社は特定上場会社等であります。特定上場会社等に該当することにより、インサイダー取引規制の重要事実の軽微基準については連結ベースの数値に基づいて判断することとなります。</span>\n</p>\n<p style="margin-left: 24px; text-align: left">\n<span style="font-family: &apos;MS Mincho&apos;; font-size: 12px">(関係会社の事業内容)</span>\n</p>\n<p style="margin-left: 24px; text-align: left">\n<span style="font-family: &apos;MS Mincho&apos;; font-size: 12px">\u3000日本マクドナルド株式会社(当社出資比率100%)は、直営店方式による店舗運営とともにフランチャイズ方式による店舗展開を通じハンバーガーレストラン事業を展開しております。同社は、米国マクドナルド・コーポレーションから許諾されるライセンスに対するロイヤルティーを支払っております。日本国内においては、フランチャイズ店舗を経営するフランチャイジーに対してノウハウ及び商標等のサブ・ライセンスを許諾し、フランチャイジーからロイヤルティーを収受しております。</span>\n</p>\n<p style="margin-left: 24px; text-align: left">\n<span style="font-family: &apos;MS Mincho&apos;; font-size: 12px; font-weight: normal">\u3000当社と関係会社との当連結会計年度における資本関係及び取引関係の概要は、以下のとおりであります。</span>\n</p>\n<p style="margin-left: 24px; text-align: left">\n<span style="font-family: &apos;MS Mincho&apos;; font-size: 12px">[事業系統図]</span>\n</p>\n<p style="text-align: center">\xa0</p>\n<p style="text-align: left">\n<img style="height: 441px; width: 566.195739746094px" src="images/0101010_002.png" alt="0101010_002.png"/>\n</p>\n<p style="text-align: left">\xa0</p>\n<p style="text-align: left">\xa0</p>\n'

html形式の文字列が抽出されました。 XBRLの各タグの要素をhtml形式で表現しているものが多く存在します。 表形式の情報などはhtml形式としてパースを行って目的の情報を抽出できます。 今回はhtmlタグを取り除く処理を行います(ついでに改行コードも除く)。

# 正規表現操作のライブラリ
import re
# 改行や空白文字を削除する
bussiness_block = re.('\s','',bussiness_block)
# htmlタグを削除する
bussiness_block = re.('<.*?>','',bussiness_block)

>>'3【事業の内容】当社グループの事業はハンバーガーレストラン事業単一であるため、セグメント情報に関連付けた記載を行っていません。(当社の事業内容)当社は、日本マクドナルド株式会社の持株会社として、グループ企業の連結経営戦略の策定業務と実行業務及び不動産賃貸業務を主たる事業としております。なお、当社は特定上場会社等であります。特定上場会社等に該当することにより、インサイダー取引規制の重要事実の軽微基準については連結ベースの数値に基づいて判断することとなります。(関係会社の事業内容)日本マクドナルド株式会社(当社出資比率100%)は、直営店方式による店舗運営とともにフランチャイズ方式による店舗展開を通じハンバーガーレストラン事業を展開しております。同社は、米国マクドナルド・コーポレーションから許諾されるライセンスに対するロイヤルティーを支払っております。日本国内においては、フランチャイズ店舗を経営するフランチャイジーに対してノウハウ及び商標等のサブ・ライセンスを許諾し、フランチャイジーからロイヤルティーを収受しております。当社と関係会社との当連結会計年度における資本関係及び取引関係の概要は、以下のとおりであります。[事業系統図]'

事業の内容項目をきれいに抽出することができました。 ファイルをモスフードサービスに変更しても,

"3【事業の内容】当社グループは、㈱モスフードサービス(当社)及び子会社12社、関連会社14社により構成されており、主にフランチャイズシステムによる飲食店の展開を事業としております。事業は大きく「モスバーガー」等の商標を使用した飲食店を展開する「モスバーガー事業」、「マザーリーフ」「MOSDO」「ミアクッチーナ」「あえん」「chef'sV」「GREENGRILL」等の商標を使用した飲食店を展開する「その他飲食事業」、これらの飲食事業を衛生、金融、保険等で支援する「その他の事業」に分けることができます。事業内容と当社及び関係会社等の当該事業における位置付け及びセグメントとの関連は、次のとおりであります。セグメントの名称主要製品主要な会社モスバーガー事業「モスバーガー」等の運営ハンバーガー、ライスバーガー、モスチキン、スープ、ドリンク等及びパティ、バンズ、ポテト等の食材並びにカップ、パッケージ等の包装資材[国内]㈱モスフードサービス㈱モスストアカンパニー[台湾]安心食品服務(股)[シンガポール]モスフード・シンガポール社安心フードサービスシンガポール社[香港]モスフード香港社香港モスバーガーインベストメント社[中国]広東摩斯貝格餐飲管理有限公司[タイ]モスバーガー・タイランド社[オーストラリア]モスバーガー・オーストラリア社[インドネシア]モグインドネシア社[韓国]モスバーガーコリア社[フィリピン]モスバーガー・フィリピン社食品製造、食材販売事業パティ、ソース類等[国内]紅梅食品工業㈱タミー食品工業㈱[台湾]魔術食品工業(股)[フィリピン]モスサプライ・フィリピン社アグリ事業トマト、レタス等[国内]㈱モスファーム熊本㈱モス・サンファームむかわ㈱モスファームすずなり㈱モスファームマルミツ㈱モスファーム信州㈱モスファーム千葉その他飲食事業喫茶紅茶、ワッフル、パスタ、スイーツ等[国内]㈱モスフードサービス㈱モスストアカンパニーレストラン和風旬菜料理、洋風旬菜料理等[国内]㈱モスフードサービス㈱モスダイニングその他の事業食品衛生検査業ハンバーガー等の衛生検査、衛生関連商品の販売[国内]㈱エム・エイチ・エス金銭貸付業フランチャイジー(加盟店)への事業資金貸付[国内]㈱モスクレジット保険代理業生命保険、損害保険[国内]㈱モスクレジットレンタル業POSレジスター、看板等[国内]㈱モスクレジットグループ内アウトソーシング事業グループ内アウトソーシング事業[国内]㈱モスシャイン以上の企業集団等について事業系統図を図示すると次のとおりであります。(注)海外における事業は「モスバーガー事業」であります。子会社及び関連会社の連結の範囲は、次のとおりであります。子会社関連会社㈱エム・エイチ・エス※紅梅食品工業㈱㈱モスクレジット※タミー食品工業㈱㈱モスストアカンパニー※安心食品服務(股)㈱モスダイニング※モスバーガー・オーストラリア社㈱モスシャイン※モスバーガーコリア社モスフード・シンガポール社※モスバーガー・タイランド社魔術食品工業(股)※モスバーガー・フィリピン社モスフード香港社㈱モスファーム熊本モスサプライ・フィリピン社㈱モス・サンファームむかわ※(モグインドネシア社)㈱モスファームすずなり※(香港モスバーガーインベストメント社)㈱モスファームマルミツ※(広東摩斯貝格餐飲管理有限公司)㈱モスファーム信州㈱モスファーム千葉安心フードサービスシンガポール社計12社計14社(注)1.()内は非連結子会社であります。2.※印は持分法適用会社であります。"

この通りきれいに内容を抽出することができました。

これでテキストの抽出ができるようになりました。

企業の課題を用いてワードクラウドを作ってみる

簡単なテキストマイニングをやってみます。 異なる2つの業界の企業から有価証券報告書の【経営方針、経営環境及び対処すべき課題等】の項目を抽出して頻出ワードを抽出してワードクラウドを作ります。

各業界のおかれている状況の違いみたいなのが見えたらいいなぁというお気持ちです。

まず,

  • SaaS企業売上の高そうな10社
  • “ゴルフクラブ”と名のついた企業10社

の有価証券報告書を用意します。

【経営方針、経営環境及び対処すべき課題等】項目のテキストを抽出します。

1文中の将来に関する事項は、当事業年度末現在において当社が判断したものであります。(1)経営方針①ゴルフ場は会員様(株主)の財産であるとの意識を高く持ち、そのハード・…略

形態素解析して名詞だけ抽出します(mecab-python3を使用)。

1文中 将来 事項 事業年度末現在 当社 判断 もの * 経営方針*ゴルフ場 会員様 株主 財産 意識 ハード …略

そこからを用いてワードクラウドを作ります(wordcloudを使用)。

SaaS企業

ゴルフ場経営企業

ワードクラウドが作成できました。 売上のあるSaaS経営企業とゴルフ場経営企業では頻出するワードの雰囲気がぜんぜん違いますね。

名詞ではなくて形容詞を用いて同様にワードクラウドを作ってみました。

SaaS企業

ゴルフ場経営企業

ところゴルフ場経営企業の厳しさが伝わってきました。

👇ソースコードはこちら👇 github.com

おわりに

今回は有価証券報告書からテキストを抽出して単なテキストマイニングを紹介しました。 次のステップとして有価証券報告書から財務情報を抽出がオススメです。 html形式から情報抽出する技術が身につけられます。

*1:ちなみに日本の上場企業数は4000社程度です

*2:ちなみに有価証券報告書の電子提出が義務付けられたのは2004年6月からですがEDINETでは直近5年間の有価証券報告書しかダウンロードできません。 全人類が思いつくであろうある企業の過去15年分の有価証券報告書を使って分析というのができないのは少し残念です。 実はこんなサイトには直近5年以上前の情報もあったりします。

*3:そもそもXMLを知らない方はこことかを読んで見るといいかもしれません。

*4:標準ライブラリに入っているxmlではなくlxmlを使用した理由は名前空間のURIマップをライブラリで取得することができるからです。