雑記

お気付きの点がありましたらご指摘いただけますと幸いです。

  1. 宮川 雅巳. 統計的因果推論―回帰分析の新しい枠組み (シリーズ・予測と発見の科学). 朝倉書店. 2004.

💜

f:id:cookie-box:20211229165400p:plain:w70

  • 何度も挙げている例ですが、「ピアノを習っているか」X から「東大に合格したか」Y への効果がどれくらいあるかを知りたいとしましょう。このとき、「ご家庭の年収」U という交絡因子があるために正しい効果の大きさを測れないことが予想されます。ご家庭の年収で層別するなどの対策は考えられるでしょう。しかし、ご家庭の年収が観測できるとは限りません。
  • そこで [1] の 89 ページでは図 5.5 (b) でいう Z なる変数を観測できていたとしたらどうか、という議論をしています。上の例であれば「ピアノのレッスンに払っていた月謝」などでしょうか。現実にはお金持ちほど高額な月謝のピアノのレッスンに通うのかもしれませんが、ここでは都合上「お金持ちほどピアノを習う傾向があるが、ピアノを習うならばご家庭の年収と月謝は独立である」と仮定しましょう。実際そこまで世の中のピアノのレッスンに月謝の格差があるわけではないのではないでしょうか。全然わかりませんが。ご家庭の年収は観測できずピアノの月謝が観測できる状況というのが考えにくいのも問題ですが致し方ありません。
  • ともかくその独立性を仮定すると、
     「ご家庭の年収とピアノの月謝の相関係数
     =「ご家庭の年収とピアノを習っているかの相関係数
      ・「ピアノを習っているかとピアノの月謝の相関係数
    です。適当な数値例をつくってみましょう。ピアノを習っている下でご家庭の年収とピアノの月謝が独立になるように注意します……この例だとピアノを習っていることの東大合格への効果 \alpha_{yx} はゼロになってしまいますね。

    import statistics
    
    def standardization(l):
        l_mean = statistics.mean(l)
        l_pstdev = statistics.pstdev(l)
        return [(i - l_mean) / l_pstdev for i in l]
    
    x = standardization([0, 0, 0, 0, 0, 0, 1, 1, 1, 1])  # ピアノを習っているか
    y = standardization([0, 0, 0, 1, 0, 0, 1, 0, 0, 1])  # 東大に合格したか
    u = standardization([1, 2, 3, 4, 5, 5, 4, 4, 5, 5])  # ご家庭の年収
    z = standardization([0, 0, 0, 0, 0, 0, 1, 2, 1, 2])  # ピアノの月謝
    
    rho_zu = statistics.correlation(z, u)
    rho_xu = statistics.correlation(x, u)
    rho_xz = statistics.correlation(x, z)
    print(f'rho_zu = {rho_zu}')
    print(f'rho_xu = {rho_xu}')
    print(f'rho_xz = {rho_xz}')
    print(f'rho_xu * rho_xz = {rho_xu * rho_xz}\n')
    # rho_zu = rho_xu rho_xz
    
    # Y = alpha_yz Z + alpha_yu U + epsilon_y ... (5.21.3)
    # => X Y = alpha_yz X Z + alpha_yu X U + epsilon_y X
    # => rho_xy = alpha_yz rho_xz + alpha_yu rho_xu
    # => alpha_yu = (rho_xy - alpha_yz rho_xz) / rho_xu ... (a)
    
    # Y = alpha_yz Z + alpha_yu U + epsilon_y ... (5.21.3)
    # => Y Z = alpha_yz Z Z + alpha_yu Z U + epsilon_y Z
    # => rho_yz = alpha_yz + alpha_yu rho_zu
    # => rho_yz = alpha_yz + alpha_yu rho_xu rho_xz ... (b)
    
    # (a) (b) から alpha_yz を解く.
    # rho_yz = alpha_yz + (rho_xy - alpha_yz rho_xz) rho_xz
    # => alpha_yz = (rho_yz - rho_xy rho_xz) / (1.0 - rho_xz rho_xz)
    
    rho_xy = statistics.correlation(x, y)
    rho_yz = statistics.correlation(y, z)
    print(f'rho_xy = {rho_xy}')
    print(f'rho_yz = {rho_yz}')
    print(f'rho_xy * rho_xz = {rho_xy * rho_xz}')
    
    alpha_yz = (rho_yz - rho_xy * rho_xz) / (1.0 - rho_xz * rho_xz)
    print(f'alpha_yz = {alpha_yz}')
    print(f'alpha_yx = rho_xz * alpha_yz = {rho_xz * alpha_yz}\n')
    
    # Y = alpha_yx X + alpha_yu U + epsilon_y ... (5.20.2)
    # => X Y = alpha_yx X X + alpha_yu X U + epsilon_y X
    # => rho_xy = alpha_yx + alpha_yu rho_xu
    # => alpha_yx = rho_xy - alpha_yu rho_xu
    alpha_yu = (rho_xy - alpha_yz * rho_xz) / rho_xu
    print(f'alpha_yu = {alpha_yu}')
    print(f'alpha_yu * rho_xu = {alpha_yu * rho_xu}')
    print(f'alpha_yx = rho_xy - alpha_yu * rho_xu = {rho_xy - alpha_yu * rho_xu}')
    
    rho_zu = 0.3957336397583147
    rho_xu = 0.4308202184276646
    rho_xz = 0.9185586535436918
    rho_xu * rho_xz = 0.3957336397583148
    
    rho_xy = 0.3563483225498992
    rho_yz = 0.3273268353539886
    rho_xy * rho_xz = 0.3273268353539886
    alpha_yz = 0.0
    alpha_yx = rho_xz * alpha_yz = 0.0
    
    alpha_yu = 0.8271392736637095
    alpha_yu * rho_xu = 0.3563483225498992
    alpha_yx = rho_xy - alpha_yu * rho_xu = 0.0
    

雑記: SQL で適当に AUC を計算すると遅い

追記: この記事には 2 通りの方法をかいているが前者の方法は遅いので後者の方法(つまり参考文献 [1] の方法)でやるのがよい。

参考文献

  1. SQLでAUCを算出する方法 |Dentsu Digital Tech Blog|note(2022年6月22日参照).
  2. Prestoでは集計関数をWINDOW関数として扱える | 分析ノート(2022年6月22日参照).



Presto で AUC を計算したいとします。ちょうど過去の記事(雑記: AUC の話とその scikit-learn での計算手順の話 - クッキーの日記)に具体例に対する計算方法があったのでまずこれと同じデータを用意します(以下)。

WITH data AS (
    SELECT 
        CAST(label AS INT) AS label,
        CAST(score AS INT) AS score
    FROM
    (
        SELECT
            '0,0,0,1,1,0,1,1' AS label_,
            '2,1,2,4,2,1,3,5' AS score_
    ) x
    CROSS JOIN
        UNNEST(
            split(x.label_, ','),
            split(x.score_, ',')
        ) AS t(label, score)
),

上の WITH 句に続けて AUC の計算を実装すると以下のようになります。実行すると 0.9375 と出てきて大丈夫です → 追記: しかしこの実装は巨大データには遅いです。

-- ROCカーブの座標
tpr_fpr AS (
    -- ROCカーブが原点から開始することを保証するために以下の一行を追加する
    SELECT
        0 AS row_num,
        0.0 AS tpr,
        0.0 AS fpr

    UNION ALL

    -- ROCカーブの座標
    SELECT
        ROW_NUMBER() OVER(ORDER BY threshold DESC) AS row_num,
        CAST(COUNT(IF(label = 1 AND predict = 1, 1, NULL)) AS DOUBLE) / COUNT(IF(label = 1, 1, NULL)) AS tpr,
        CAST(COUNT(IF(label = 0 AND predict = 1, 1, NULL)) AS DOUBLE) / COUNT(IF(label = 0, 1, NULL)) AS fpr
    FROM
    (
        -- 各閾値候補に全データを LEFT JOIN してその閾値での判定を付加する
        SELECT
            threshold,
            label,
            score,
            CASE
                WHEN score >= threshold THEN 1
                ELSE 0
            END AS predict
        FROM
        (
            SELECT DISTINCT
                score AS threshold
            FROM
                data
        )
        LEFT JOIN
            data
        ON
            TRUE
    )
    GROUP BY
        threshold
)

-- 台形則でカーブ下の面積を算出
SELECT
    SUM(area) AS auc
FROM
(
    SELECT
        -- 0.5 * (上底 + 下底) * 高さ
        0.5 * (a.tpr + b.tpr) * (b.fpr - a.fpr) AS area
    FROM
        tpr_fpr a
    JOIN
        tpr_fpr b
    ON
        a.row_num + 1 = b.row_num
)
(0.9375,)

しかし、インターネット上でみつけた参考文献 [1] をみると上の実装よりもずっとすっきりしていることがわかります。この要因は、参考文献 [2] にあるように集計関数を WINDOW 関数として扱っていなかったことと、LAG 関数を利用していなかったことにあるので、この 2 点を直すと以下になります。ちなみに参考文献 [1] はラベルが 1 か 0 であることを前提にもっとすっきりさせているので参考文献 [1] をみてください。

tpr_fpr AS (
    -- ROCカーブの座標
    SELECT
        score,
        CAST(COUNT(IF(label = 1, 1, NULL)) OVER(ORDER BY score DESC) AS DOUBLE) / COUNT(IF(label = 1, 1, NULL)) OVER() AS tpr,
        CAST(COUNT(IF(label = 0, 1, NULL)) OVER(ORDER BY score DESC) AS DOUBLE) / COUNT(IF(label = 0, 1, NULL)) OVER() AS fpr
    FROM
        data
)

-- 台形則でカーブ下の面積を算出
SELECT
    SUM(area) AS auc
FROM
(
    SELECT
        -- 0.5 * (上底 + 下底) * 高さ
        0.5 * (tpr + LAG(tpr, 1, 0.0) OVER (ORDER BY score DESC)) * (fpr - LAG(fpr, 1, 0.0) OVER (ORDER BY score DESC)) AS area
    FROM
        tpr_fpr
)
(0.9375,)

なお、ROC カーブはスコアが取りうる値が限られている(例えばいくつかの離散値である)ときはデータ数に比べて実質的なカーブの座標の点数は小さくなりますが、後者の実装では必ずデータ数だけの座標(実際には曲線の形を変えない)を出すことにはなります。それを出したくないなら CASE 文を使って必要なときだけ集計 WINDOW 関数すればいいですがそれで処理時間に恩恵があるのかわかりません。では前者の実装はというとデータ数が大きくスコアが連続的なときは JOIN ON TRUE の結果が巨大になりそうですがだからだめかはやってみていないのでわかりません → 追記:やってみたら前者が遅かったです。なんやかんやいう以前に内部で JOIN ON TRUE するオーバーヘッドがすごいんだと思います(適当)。

雑記: 学習し過ぎてほしくない話

お気付きの点がありましたらご指摘いただけますと幸いです。

まとめ

  • そのデータの真のメカニズムに比してモデルの表現力が過剰だと過学習が起きうる。つまり、学習してほしくない訓練データ上のノイズまで学び取られることで汎化性能が損なわれてしまう。
  • 必要十分な表現力のモデルを選択するには、モデルの自由度が明確であれば情報量規準が有効である。
  • ディープニューラルネットでは情報量規準が有効でないが、以下のようなプラクティスが知られる。つまり、表現力が過剰であることは前提に、訓練データを学習し切ってしまうことを妨げるアプローチといえる。
    1. 重みが取りうる値を制約する ― L2正則化(weight decay)、L1正則化(sparse DNNs)、活性化関数の出力に対する正則化、重みのL2ノルムの上限を直接制約、……。
    2. (ミニバッチごとに)訓練する重みを一部に絞る ― ドロップアウト
    3. その他の方法で訓練を妨げる ― 訓練を早めに切り上げる、訓練データにノイズを添加する、……。
  • 上記のプラクティスとは別に、確率的勾配降下法自体に暗黙裡に過学習らしい解を避ける仕組みがあるともいえる。他方、明示的に過学習らしい解を避けようとする最適化アルゴリズムも存在する。
そもそも過学習とは & 理論的な対処法

そもそも過学習(overfitting)とは何ですか? どうも望ましくない状態のようですが、モデルを学習しておいて「学習し過ぎだ」とは随分勝手な物言いなのではないでしょうか。

ウィキペディア [1] の冒頭によると、モデルの過学習とは、モデルが本当にモデリングしたいデータの特定の部分集合に適合し過ぎていて、それ以外の部分に適合できていない状態のことだね。訓練データへの予測精度の割に本番データへの予測精度が出ていない状態を思い浮かべればいいよ。

なるほど。しかしその「適合し過ぎ」とは何なのでしょう? 本番データ上で思った予測精度が出なかったとして、それはただただ上手くいかなかっただけですよね? 「やり過ぎ」であったことになるんですか?

それについてウィキペディアの続きだけど、過学習といったときはモデルが過剰な表現力をもつことを含意しているね。パラメータ数が over というわけだ。

例えば、とある A 県では「マンションの家賃」の真のモデルが「築年数」の2次関数とホワイトノイズのみでできているとしよう。このとき、家賃を予測するモデルを築年数の3次関数にすると過学習が起きうる。築年数の3次の係数が過剰なパラメータになるからね。この過剰な係数はもっぱら訓練データ上のノイズを学ぶだろう。そしてそれは本番データの予測に害をなすことになる。次数の過剰に限らず、予測モデルに「床面積」や「駅からの徒歩分数」といった過剰な変数が入っているときも同様だね。

いやいや家賃が床面積や駅からの徒歩分数などに依存しないんですか!? どれだけ築年数しかみない県民性なんですか……しかし、過学習とはモデルに過剰な表現力があることで予測に寄与しないノイズまで学び取られてしまうことに起因しているのですね。であれば、表現力が必要十分なモデルを採用すれば……いえ、最初から必要な表現力がわかっていることなどほとんどないでしょうね。真のマンションの家賃が何次関数で決まるとわかっていたら苦労しません。ではいったいどのようなモデルを用意すべきなのか……訓練データから知るすべなどないのでは……?

例えば、先の家賃予測モデルを A 県のマンションの家賃データから 100 件抽出した訓練データで学習するとするよ。モデルを築年数の1次関数、2次関数、3次関数……とするにつけ訓練データ上での予測精度は向上するだろう。でも表現力が過剰なモデルは訓練データ外の予測精度がむしろ劣化していく。だから、A 県内のあらゆるマンションに対する予測精度を出してその期待値を計算すればいい。

おお、それはよいアイデアです……とでもいうと思ったのですか? それでは A 県内のあらゆるマンションに対する正解が手に入っているでしょう。それが手に入っているなら予測する必要がありません。

その通り、本当に期待値を計算するんじゃないよ。訓練データだけから見積もるんだ。そしてその見積もった値は見積もり方によって赤池情報量規準AIC)とかベイズ情報量規準(BIC)とかよばれる。これらの情報量規準は、複雑なモデルに対してはそのぶん「訓練データ上にたまたま出たノイズまで学んで精度が高めに出ているだろう」としてペナルティを課す。このペナルティを課されても精度が上がっていくなら複雑にしていけばよい、という判断ができる [詳しくは関連記事参照]。

ディープニューラルネットへの実践的な対処法

なるほど……でしたら、過学習を防ぐには、情報量規準によって表現力が必要十分なモデルを選択すればよいのですね? 過剰な表現力がなければ過学習は起きないのですから。

常にそうするというわけでもない。例えば多項式モデルの次数を AIC で決定するのはよくみるというか、AIC が紹介されている文献に使用例として載っていることが多いと思う。これは多項式モデルは「自由度」が明確だからだろう。3次多項式は定数項含め自由度4というように。でも、一般のモデルは必ずしも自由度が明確ではない。見かけのパラメータ数とモデルの自由度が一致するとは限らない。自由度が明確でなければ情報量規準によるモデル選択は有効でなくなってしまう [詳しくは関連記事参照]。

ディープニューラルネットで情報量規準によるモデル選択がなされない主な要因もこれだと思う。参考文献 [2] [3] でも趣旨としてはそう回答されていたし。いや、調べたらニューラルネットに情報量規準を適用する例をみかけないこともなかったんだけど、浅い限定的なアーキテクチャなんじゃないかな(みつけたのは古い文献だったからリンクしていないけど)。そもそも一般にディープニューラルネットってネットワークのすべてのパラメータがフル活用されてはいないと思うしね。何にせよ、ディープニューラルネットでは情報量規準を論じづらいと思う。

えっと、要するに、情報量規準によるモデル選択はディープニューラルネットには向かない、ですか。それでは、ディープニューラルネット過学習を防ぐ方法はないのでしょうか?

そんなことはないよ。そもそもクロスバリデーションでモデル選択ができるし。これはディープニューラルネットに限らないね。A 県内のあらゆるマンションに対する正解がないなら、手元のデータの一部を評価用に取り分けておけばいいだけだ。

でそれとは別に、一般のディープニューラルネット過学習を防ぐプラクティスとして、「もう表現力が過剰なんだろうから学習を妨害しておこう」、といったものたちがあるね。[4] 及び [5] の 13.5 節に紹介されている手法を以下の 3 タイプに整理したよ。まあ3タイプ目が「その他」って感じだけど……。

  1. 重みが取りうる値を制約する ― L2正則化(weight decay)、L1正則化(sparse DNNs)、活性化関数の出力に対する正則化、重みのL2ノルムの上限を直接制約 [6]……。
  2. (ミニバッチごとに)訓練する重みを一部に絞る ― ドロップアウト
  3. その他の方法で訓練を妨げる ― 訓練を早めに切り上げる、訓練データにノイズを添加する、……。
これらとは別に [5] の 13.5 節にはベイジアンニューラルネットも紹介されているけど、こうなるともうモデルが別物って感じがするね。[4] の最後の方には「じゃあ過学習の防ぎ方の最近のトレンドは?」という話があるね。この記事自体が 3 年前の記事ではあるけど……。

ディープニューラルネットでは学習を妨害することで過学習を防ぐのですか? おいそれと妨害してよいものなのでしょうか。1. の重みの制約からみていくと、正則化というのは損失にペナルティを課すということですよね。全体的に、重みが大きいときはペナルティを課すということになるのでしょうか。重みが大きくなりづらくなると。そのような制約が正当化されるのですか?

[4] の Reduce Overfitting by Constraining Model Complexity という節の後半あたりにあるけど、重みが大きいということはそれだけ入力の小さな変化に対して出力が大きく変動するモデルということだ。でも私たちはモデルにあまりそのような挙動を期待しない。例えば、マンション B とマンション C の築年月が1ヶ月しか違わなかったとする。きっと両マンションの家賃は近いだろう。逆に、両マンションの家賃がかけ離れていたとしたら、どちらかがノイズを含んでいる可能性が高い。重みを制約することで、そのようなデータに適合しづらくなる効果が見込めるんじゃないか。重みや活性化関数の出力値の正則化は、「入力空間内で出力は急激に変化しないはずだ」という信念で正当化されると思う。

出力を大きく変動させるにはペナルティを乗り越えられるだけたくさんのデータによる証拠が必要ということですか。

ちなみに参考文献 [5] にはL1正則化はあまり用いられないとあるね。L1正則化はパラメータを疎にする効果があるけど、近年の GPU ではそれで計算量低減効果が得にくいかららしい。ただ代わりにブロックスパースというやり方はみられるというようにある。

あれ、いつから計算量の話に……。

適切な正則化は学習を高速化する期待があるから計算量と話がセットになっているんじゃないか。次のプラクティスはドロップアウトだね。参考文献 [5] によると、一定割合のニューロンが欠落しても機能するように学習させることで、複雑すぎる学習を抑制する効果があるとある。ドロップアウトによって学習したモデルは混合モデルとみなせるとかベイズ推測とみなせるとかの研究もあるよね。

ドロップアウトは、重みに対してベルヌーイ分布にしたがって(つまりコイン投げの要領で)ゼロになるノイズがのっているとみなせるともありますね。例えば訓練時に 0.8 の割合のニューロンのみ生かして予測時にドロップアウトしないならば重みにその 0.8 を乗じなければならないとも。それはそうですが。

ベルヌーイ分布のくだりは、じゃあ連続分布のドロップアウトも考えられるという布石かと思ったけどそんな話もないか。続きには、望むなら予測時にだってドロップアウトしていいとあるね。例えば10パターンのドロップアウトで10パターンの出力を得る。それぞれに 0.1 倍をかけて足し合わせる、と。ここまですると最早モデルとして別物だよね。計算もなんか面倒そうだし……でもこれがベイズ推測のよい近似になるのか。

最適化アルゴリズムによる暗黙裡な対処法

参考文献 [5] の 453 ページの 13.5.6 節は何を言わんとしているのでしょうか。ページの上部に広い谷と狭い谷のイラストがありますが。

パラメータ平面上に、訓練データにおける損失をプロットした絵だろうね。それで、左側の広い谷を目指せということだね。左側の広い谷は、底が広がっているからどこが最小点か不確かだ。不確かということは、このあたりのパラメータは訓練データに特有のノイズを拾っていない可能性が高い、という論法だね。じゃあどれくらい広ければいいのかって話になるけど、ここでいいたいのは、確率的勾配降下法SGD)はノイズを利用することで暗に狭い谷に陥ることを避けていると。でも明示的に広い谷を目指す方法もあるみたいでそこに列挙されているね。

では、次の 454 ページのイラストは……?

フルバッチに対する損失で広い谷になっているところが、個々のミニバッチでは狭い谷になっているかもしれないという絵だね。これについての考察が ICLR 2021 で発表されていたみたいだね [7]。式 (13.100) のように、ミニバッチに対する確率的勾配降下法はミニバッチごとで勾配が異なるような箇所の損失を上げ底する効果がある、つまり、ミニバッチごとで勾配が異なるような箇所はよい解と認めない効果があることを示したと。ただ式 (13.100) は少なくともナブラ記号が抜けている誤植があるし原論文とノーテーションが違うから式は原論文をみた方がいいと思う。

終わり

雑記: 百人一首に「秋風」が登場する歌は3首あるので逆文書頻度は log(100/3)

まとめ

sklearn.feature_extraction.text.TfidfVectorizer で TF-IDF 値を出すとデフォルトでは[その文書内でのその単語の出現回数]×[log( (1 + 全文書数) / (1 + その単語が出現する文書数) ) + 1]を文書ごとに L2 正規化したものである。

参考文献

  1. tf-idf - Wikipedia(2022年6月5日参照).
  2. sklearn.feature_extraction.text.TfidfVectorizer — scikit-learn 1.1.1 documentation(2022年6月5日参照).
  3. 6.2. Feature extraction — scikit-learn 1.1.1 documentation(2022年6月5日参照).



参考文献 [1] によると TF-IDF とは、文書集合内の各文書内の各単語に定義される数値であって、その文書内でその単語が占める割合(TF)と、全文書のうちその単語が出現する文書が占める割合の逆数の対数(IDF)をかけたものである。ただしこれは参考文献 [1] の表で「標準的な」とされている定義であり、TF にも IDF にも亜種は多い。ともかく標準的な TF-IDF の定義を素朴に計算してみる。適当な文書集合が思いつかないので百人一首を使用する(Wikipedia の「百人一首」の「歌一覧」の表記を採用する)。そうすると以下のようになる。

import numpy as np
import pandas as pd
pd.set_option('display.unicode.east_asian_width', True)
import MeCab
# 関係ないが NEologd を使用するとかえって「神のまにまに」などが1形態素になる
# tagger = MeCab.Tagger('-u "C:/Program Files/MeCab/dic/ipadic-neologd/NEologd.dic"')
tagger = MeCab.Tagger()

def to_words(s):  # 文章を単語にする関数であれば何でもよい
    tokens = tagger.parse(s).split('\n')
    tokens = [t.split('\t') for t in tokens if len(t) > 1]
    tokens = [t[0] for t in tokens if len(t) > 1]
    return tokens

docs = {}  # 文書IDをキーに各文書の情報を格納する辞書
vocab = {}  # 単語をキーに各単語の情報を格納する辞書
with open('data.txt') as ifile:  # 百人一首を各行に記述したテキストファイル
    for i, line in enumerate(ifile):
        s = line.strip()
        words = to_words(s)
        n_word = len(words)
        docs[i + 1] = {'文書': s, '単語列': words, '出現頻度': {}, 'TF': {}}
        words_unique = list(dict.fromkeys(words))
        for word in words_unique:
            freq = words.count(word)
            docs[i + 1]['出現頻度'][word] = freq
            docs[i + 1]['TF'][word] = float(freq) / n_word
            if word not in vocab:
                vocab[word] = {'出現文書ID': []}
            vocab[word]['出現文書ID'].append(i + 1)

n_doc = len(docs)
for word, d in vocab.items():
    doc_freq = len(d['出現文書ID'])
    vocab[word]['文書頻度'] = doc_freq
    vocab[word]['IDF'] = np.log(n_doc / doc_freq)

for id_ in range(1, 101):
    docs[id_]['文書頻度'] = {}
    docs[id_]['IDF'] = {}
    docs[id_]['TF-IDF'] = {}
    for word, freq in docs[id_]['TF'].items():
        docs[id_]['文書頻度'][word] = vocab[word]['文書頻度'] 
        idf = vocab[word]['IDF'] 
        docs[id_]['IDF'][word] = idf
        docs[id_]['TF-IDF'][word] = freq * idf
        

print('文書数', n_doc)
print('語彙数', len(vocab))

id_ = 79
print(f'\n◆ {id_}首目の情報')
print(docs[id_]["単語列"])
print(f'{len(docs[id_]["単語列"])} 単語 ({len(docs[id_]["出現頻度"])} ユニーク単語)')
print(pd.DataFrame({k: docs[id_][k].values() for k
                    in ['出現頻度', 'TF', '文書頻度', 'IDF', 'TF-IDF']},
                   index=docs[id_]['出現頻度'].keys()))
文書数 100
語彙数 633

◆ 79首目の情報
['秋風', 'に', '棚引く', '雲', 'の', '絶間', 'より',
 'もれ', '出', 'づる', '月', 'の', '影', 'の', 'さやけ', 'さ']
16 単語 (14 ユニーク単語)
        出現頻度      TF  文書頻度       IDF    TF-IDF
秋風           1  0.0625         3  3.506558  0.219160
に             1  0.0625        58  0.544727  0.034045
棚引く         1  0.0625         1  4.605170  0.287823
雲             1  0.0625         5  2.995732  0.187233
の             3  0.1875        85  0.162519  0.030472
絶間           1  0.0625         1  4.605170  0.287823
より           1  0.0625         4  3.218876  0.201180
もれ           1  0.0625         1  4.605170  0.287823
出             1  0.0625        10  2.302585  0.143912
づる           1  0.0625         1  4.605170  0.287823
月             1  0.0625        11  2.207275  0.137955
影             1  0.0625         1  4.605170  0.287823
さやけ         1  0.0625         1  4.605170  0.287823
さ             1  0.0625        10  2.302585  0.143912

上では 79 首目の情報のみ表示しているが、この歌は 16 単語からなるので各単語の相対頻度である TF は 0.0625 である。しかし、「の」だけは 3 回登場するので 3 倍になっている。また、「秋風」が登場する歌はこの歌を含めて 3 首あり、「秋風」の IDF は log(100/3)≒3.5 になっている。他方、「棚引く」はこの歌にしか登場しないので IDF は log(100/3)≒4.6 になっている。結果、「秋風」「棚引く」の TF-IDF は 0.219, 0.288 になっている、などがわかる。


scikit-learn で同じ処理をしたい。feature_extraction.text.TfidfVectorizer [2] を使う。文書を単語に分割する関数はさっきと同じものを使う。と、当然語彙数は同じになる。しかし、「秋風」「棚引く」の TF-IDF は 0.274, 0.318 になっており、さっきとはずれている。というか IDF が既にずれている。scikit-learn の IDF の方が大きい。

import warnings
from sklearn.feature_extraction.text import TfidfVectorizer
warnings.simplefilter('ignore', UserWarning)

vectorizer = TfidfVectorizer(tokenizer=to_words)
texts = np.array([docs[id_]['文書'] for id_ in range(1, 101)])
vectorizer.fit(texts)
tfidf = vectorizer.transform(texts)
feature_names = vectorizer.get_feature_names_out()

print('文書数', tfidf.shape[0])
print('語彙数', len(feature_names))

id_ = 79
print(f'\n◆ {id_}首目の情報')
print('\tIDF\t\t\tTF-IDF')
v = tfidf[id_ - 1].todense()
for idx in tfidf[id_ - 1].indices:
    print(f'{feature_names[idx]}\t{vectorizer.idf_[idx]}\t{v[0, idx]}')
文書数 100
語彙数 633

◆ 79首目の情報
	IDF			TF-IDF
雲	3.8233610476132043	0.24736881778628844
絶間	4.921973336281314	0.3184482737071388
秋風	4.228826155721369	0.2736021300990034
棚引く	4.921973336281314	0.3184482737071388
月	3.130213867053259	0.20252267417815306
影	4.921973336281314	0.3184482737071388
出	3.217225244042889	0.20815225014334174
より	4.005682604407159	0.25916489652419133
もれ	4.921973336281314	0.3184482737071388
の	1.160773220587752	0.2253036757860151
に	1.53758307293554	0.09948056232819362
づる	4.921973336281314	0.3184482737071388
さやけ	4.921973336281314	0.3184482737071388
さ	3.217225244042889	0.20815225014334174


そこで参考文献 [3] をみると scikit-learn の TF-IDF はデフォルトで以下の (1) (2) (3) の定義を採用しているようなので素朴な計算においてこれらの定義をそろえれば scikit-learn の結果とそろう。

from sklearn.preprocessing import normalize

for id_ in range(79, 80):
    docs[id_]['スムーズIDF'] = {}
    v = []
    for word, freq in docs[id_]['出現頻度'].items():  # (1) TF は相対度数ではない生の出現頻度
        # (2) IDF は分子と分母に1を足しさらに全体に1を足す
        idf_ = np.log((1.0 + n_doc) / (1.0 + vocab[word]['文書頻度'])) + 1.0
        docs[id_]['スムーズIDF'][word] = idf_
        v.append(freq * idf_)
    v = normalize(np.array([v]), norm='l2')  # (3) L2正規化する
    docs[id_]['正規化スムーズTF-IDF'] = {}
    for i, (word, _) in enumerate(docs[id_]['TF'].items()):
        docs[id_]['正規化スムーズTF-IDF'][word] = v[0, i]

id_ = 79
print(f'\n◆ {id_}首目の情報')
print(f'{len(docs[id_]["単語列"])} 単語 ({len(docs[id_]["出現頻度"])} ユニーク単語)')
print(pd.DataFrame({k: docs[id_][k].values() for k
                    in ['出現頻度', 'スムーズIDF', '正規化スムーズTF-IDF']},
                   index=docs[id_]['出現頻度'].keys()))
◆ 79首目の情報
16 単語 (14 ユニーク単語)
        出現頻度  スムーズIDF  正規化スムーズTF-IDF
秋風           1     4.228826              0.273602
に             1     1.537583              0.099481
棚引く         1     4.921973              0.318448
雲             1     3.823361              0.247369
の             3     1.160773              0.225304
絶間           1     4.921973              0.318448
より           1     4.005683              0.259165
もれ           1     4.921973              0.318448
出             1     3.217225              0.208152
づる           1     4.921973              0.318448
月             1     3.130214              0.202523
影             1     4.921973              0.318448
さやけ         1     4.921973              0.318448
さ             1     3.217225              0.208152

雑記

お気付きの点がありましたらご指摘いただけますと幸いです。

  1. 宮川 雅巳. 統計的因果推論―回帰分析の新しい枠組み (シリーズ・予測と発見の科学). 朝倉書店. 2004.

💜

f:id:cookie-box:20211229165400p:plain:w70
  • [1] の 76 ページの図 5.1 (c) のとき、つまり、交絡因子 Z があるときの介入効果は以下ですね。
     \displaystyle \begin{split} f(y| {\rm set}(X=x)) &= \int \frac{f_V(x, y, z)}{f_{X \cdot Z}(x| z)} dz \\ &= \int \frac{ f_Z(z) f_{X \cdot Z}(x| z) f_{Y \cdot XZ}(y|x,z) }{f_{X \cdot Z}(x| z)} dz \\ &= \int f_Z(z) f_{Y \cdot XZ}(y|x,z)dz \end{split} \tag{5.5}
    ピアノと東大生の例でいうと、実家がお金持ちかが交絡因子 Z です。Z という変数がなければこの介入効果は単に「対象データ中でピアノを習っていた人のうち何人が東大生か」ですが、Z があるので実家の収入ごとにこれを考えて重み付き平均します。