EC2でJupyterサーバーを構築する

今となってはGoogleのColaboratoryがあるのであまりニーズがないのですが、
EC2でJupyter notebook環境を構築する方法についてまとめておきます。

前提ですが、OSはAmazon Linux 2を利用し、Pythonの仮想環境はpyenvで作ります。
また、ポートはデフォルトの8888番を使用し、アクセスにはパスワードをかけます。
また、作業ディレクトリは/var/notebookとします。

手順1. インスタンスを立てる

Amazon Linux 2 のAMIを選択し、EC2インスタンスを立てます。
このときセキュリティグループではssh接続に使用する22番ポートと、
Jupyter Notebookに接続するポート(デフォルトでは8888番)を開けておきます。

手順2. pyenv必要なモジュールのインストール

pyenv を使うためのモジュールを順番に入れていきます。
最初にyumを最新化し、git、次にpyenvのドキュメントで指定されているモジュール群を入れます。
FIXME: you may need to install xz to build some CPython version
とありますが、xzを入れておかないと新しめのバージョンのpandasをインポートしたときに、
lzma moduleがないと言う旨の警告が出るのでxzも入れておきます。


# yumを最新の状態にする
$ sudo yum update
# gitのインストール
$ sudo yum install git
# 必要なモジュールのインストール
$ sudo yum install gcc zlib-devel bzip2 bzip2-devel readline-devel
$ sudo yum install sqlite sqlite-devel openssl-devel tk-devel libffi-devel
$ sudo yum install xz xz-devel

手順3. pyenv本体のインストール

ドキュメントの手順にそってインストールします。
リポジトリをcloneし、.bash_profileに設定を入れていきます。
入れたら.bash_profileを再度読み込み、インストールの結果確認としてバージョンを表示します。


$ git clone https://github.com/pyenv/pyenv.git ~/.pyenv
$ echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bash_profile
$ echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bash_profile
$ echo -e 'if command -v pyenv 1>/dev/null 2>&1; then\n  eval "$(pyenv init -)"\nfi' >> ~/.bash_profile

# .bash_profileを読み込む
$ source .bash_profile
# バージョンの確認
$ pyenv --version
pyenv 1.2.23-75-g80e418ec

手順4. Pythonのインストール

pyenvでインストールできるバージョンを確認し、インストールします。
今回は3.8.8を入れることにしました。
このため、notebookは別途インストールします。
ここで、Anacondaを選ぶと、次のステップのnotebookのインストールを飛ばすことができます。


# インストールできるバージョンの一覧を確認する
$ pyenv install -l
# 3.8.8 をインストールする
$ pyenv install 3.8.8
# 3.8.8 に切り替え
$ pyenv global 3.8.8
$ pyenv rehash
# pythonのバージョンを確認
$ python --version 
Python 3.8.8

手順5. Jupyter Notebookのインストール

pipを最新化し、Jupyterをインストールします。
Anacondaを使う場合はこの手順は不要です。


$ pip --version
pip 20.2.3 from /home/ec2-user/.pyenv/versions/3.8.8/lib/python3.8/site-packages/pip (python 3.8)
$ pip install --upgrade pip
$ pip --version
pip 21.0.1 from /home/ec2-user/.pyenv/versions/3.8.8/lib/python3.8/site-packages/pip (python 3.8)

# Jupyter Notebookのインストール
$ pip install notebook

手順6. パスワードトークンの取得

設定ファイルにセットするため、パスワードのハッシュを入手しておきます。
次のコードを実行すると、パスワードを確認含めて2回聞かれますのでそれぞれ入力します。
そして、出力された文字列をどこかに記録しておきます。
昔は sha1:xxxxxxx みたいな文字列でしたが、最近は argon2:xxxxxxx のようです。
アルゴリズムが変わったみたいですね。


$ python -c 'from notebook.auth import passwd;print(passwd())'
Enter password:
Verify password:
argon2:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

手順7. 作業ディレクトリ作成

notebookのホームディレクトリとなる作業ディレクトリを作成します。
どこに作ってもいいと思うのですが、自分は/var配下に作るのが好みなので/var/notebookとしています。
本当は専用のユーザーを作った方がいいと思うのですが、自分はそのままec2-userで動かすので、
作ったディレクトリにはec2-userが書き込みができるように権限設定しておきます。


$ cd /var
$ sudo mkdir notebook
$ sudo chown ec2-user:ec2-user notebook

手順8 設定ファイル生成とバックアップ

Jupyter Notebookの設定ファイルを作成し、バックアップをとっておきます。
メッセージに表示されている通り、
/home/ec2-user/.jupyter/jupyter_notebook_config.py
と言うファイルが生成されます。


$ jupyter notebook --generate-config
Writing default config to: /home/ec2-user/.jupyter/jupyter_notebook_config.py
$ cd .jupyter/
$ cp jupyter_notebook_config.py jupyter_notebook_config.py.org

手順9 必要な設定を行う

vimか何かで設定ファイルを開き、必要な設定を施していきます。
vim jupyter_notebook_config.py

必要なのは以下の4行です。


c.NotebookApp.ip = '0.0.0.0'
c.NotebookApp.open_browser = False
c.NotebookApp.notebook_dir = '/var/notebook'
c.NotebookApp.password = 'argon2:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

c.NotebookApp.ip の設定は ‘0.0.0.0’ の代わりに ‘*’でも構いません。
デフォルトはlocalhostになっていますが、これにより外部からのアクセスが許可されます。
c.NotebookApp.open_browser = False
は起動時にブラウザを立ち上げない設定です。
c.NotebookApp.notebook_dir はホームディレクトリの設定なので、先ほど作った/var/notebookを設定します。
c.NotebookApp.password
には先ほど保存しておいたパスワードトークンを設定してください。

手順10 起動と接続テスト

これで、準備は整いましたのでnotebookを立ち上げます。


$ jupyter notebook

あとは、
http://{ec2のパブリックIP}:8888
にアクセスすると、Jupyter Notebookのログイン画面が表示されます。
パスワードトークン生成時に入力したパスワードでログインできたら成功です。

この状態だと、ec2インスタンスを起動するたびに、sshで入ってJupyterを起動する必要があり若干手間なので、
次の記事ではインスタンス起動時に自動的にJupyterが立ち上がるように設定する方法を紹介する予定です。

PythonでURL文字列を構築する

前回の記事がURL文字列の各要素への分解だったので、今回は逆に構築です。

まずはクエリパラメーター(クエリストリング)部分についてです。
今更説明するまではないのですが、クエリパラメーターはキーと値を=(イコール)で結び、さらにそれらを&で繋いでいきます。
自分で実装すると地味に手間なのですが、これもurllibに実装があります。

それが urllib.parse.urlencode です。

試しにやってみます。


from urllib import parse

qs = parse.urlencode(
    {
        "key1": "キー1",
        "value1": "値1",
        "id": "123"
    }
)
print(qs)
# key1=%E3%82%AD%E3%83%BC1&value1=%E5%80%A41&id=123

この例で気づかれたと思いますが、このメソッド、とても気の利いてることにパーセントエンコーディングもやってくれます。
辞書の他に、(キー, 値)のタプルの配列も引数として受け取ってくれます。


qs = parse.urlencode(
    [
        ("key1", "キー1"),
        ("value1", "値1"),
        ("id", "123")
    ]
)
print(qs)
# key1=%E3%82%AD%E3%83%BC1&value1=%E5%80%A41&id=123

クエリパラメーターの文字列を構築するのに、非常に便利ですね。

もう一つ、プロトコルやホスト名、パスなどからURL全体を構築するメソッドもあります。
それが、 urllib.parse.urlunparse です。

scheme, netloc, path, parameters, query, fragment の 6要素のタプルを受け取り、
scheme://netloc/path;parameters?query#fragment の形に結合してくれます。
タプルないの要素は省略は不可で、それを含まないようなURLを作るときは空白文字列を入れないといけません。

正直、そこまで便利には感じませんでした。

一応やってみた例です。


print(
    parse.urlunparse(
        (
            "https",
            "analytics-note.xyz",
            "",
            "",
            "s=統計学",
            "",
        )
    )
)
# https://analytics-note.xyz?s=統計学

上の例で分かる通り、クエリストリングはパーセントエンコーディングやってくれません。
そのため、クエリパラメーターに日本語を含む場合は本当は事前に何らかの手段でパーセントエンコーディングしておく必要があります。

urlunparse はそこまで使うメリットがなさそうなので、単純に文字列の結合を実装しても良さそうです。

PythonでURL文字列を要素に分解する

前回に引き続きurllibの話です。
url文字列ははプロトコル、ホスト名、パス、パラメーターなど、いくつかの要素で成り立っています。
urllibを使うとURLの文字列から簡単に各要素を取り出せます。
使うのはurllib.parse.urlparseです。
ドキュメント: urllib.parse.urlparse

使うのは簡単で、URLの文字列を渡すだけです。結果のオブジェクトのプロパティとして各要素を取り出せます。
次の形で分解してくれます。
URL: scheme://netloc/path;parameters?query#fragment

ドキュメントのページのURLを例にしてやってみましょう。


from urllib import parse

url = "https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlparse"
result = parse.urlparse(url)

# 結果はParseResultオブジェクト
print(result)
# ParseResult(scheme='https', netloc='docs.python.org', path='/3/library/urllib.parse.html', params='', query='', fragment='urllib.parse.urlparse')

# ホスト名
print(result.netloc)
# docs.python.org

# パス
print(result.path)
# /3/library/urllib.parse.html

上の例では存在しませんが、 query でパラメーターが渡されていた場合、その値をパースしたいこともよくあります。
それには、parse_qs を使います。
ドキュメント: urllib.parse.parse_qs
もしくはparse_qslでも構いません。違いは出力が辞書かリストかです。
ドキュメント: urllib.parse.parse_qsl

パラメーターの多いyahoo ファイナンスさんのURLを拝借してやってみましょう。


url = "https://info.finance.yahoo.co.jp/history/?code=998407.O&sy=2021&sm=1&sd=12&ey=2021&em=1&ed=31&tm=d"
result = parse.urlparse(url)
# queryが取得できた
print(result.query)
# code=998407.O&sy=2021&sm=1&sd=12&ey=2021&em=1&ed=31&tm=d

# parse_qs でパースした結果
print(parse.parse_qs(result.query))
# {'code': ['998407.O'], 'sy': ['2021'], 'sm': ['1'], 'sd': ['12'], 'ey': ['2021'], 'em': ['1'], 'ed': ['31'], 'tm': ['d']}

# parse_qslでパースした結果
print(parse.parse_qsl(result.query))
# [('code', '998407.O'), ('sy', '2021'), ('sm', '1'), ('sd', '12'), ('ey', '2021'), ('em', '1'), ('ed', '31'), ('tm', 'd')]

Pythonでパーセントエンコード(URLエンコード)

パーセントエンコードの詳しい説明はWikipedia参照。
参考: パーセントエンコーディング – Wikipedia

要するに、日本語などURLの中で使えない文字列を表示する方法です。
上記のWikipediaのリンクも、「パーセントエンコーディング」に相当する部分が
%E3%83%91%E3%83%BC%E3%82%BB%E3%83%B3%E3%83%88%E3%82%A8%E3%83%B3%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0
になっています。

このようなエンコードと、デコードをPythonで行う方法を調べました。
結論として、url関係のライブラリである、urllibで実装できるようです。

まず、エンコード。
これは、urllib.parse.quote でできます。


from urllib.parse import quote
print(quote("パーセントエンコーディング"))
"""
%E3%83%91%E3%83%BC%E3%82%BB%E3%83%B3%E3%83%88%E3%82%A8%E3%83%B3%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0
"""

次に、逆変換(デコード)です。
これは、urllib.parse.unquote でできます。
パーセントエンコーディングだと長いので、サンプルを少し短くしました。


from urllib.parse import unquote
print(unquote("%E3%83%91%E3%83%BC%E3%82%BB%E3%83%B3%E3%83%88"))
"""
パーセント
"""

kerasのTokenizerで文書をtfidfベクトル化したときの計算式について

前回で終わりにしようと思っていたのですが今回もtfidfの記事です。
あまり使う機会はないのですが、kerasのテキストの前処理機能である、
keras.preprocessing.text.Tokenizer にも実はコーパスをtfidf化する機能が実装されています。

ドキュメントを読んでもtfidf機能があること自体が書いてありません。(したがって、実装されているtfidfの定義も書いてありません。)
参考: テキストの前処理 – Keras Documentation
そして、実際に動かしてみると、一般的な定義とは違う定義で動いているようなのでどのような計算式なのか調べました。

とりあえずいつもの例文で動かしてみます。
動かし方は、インスタンスを作り、fit_on_textsでコーパスを学習した後、
texts_to_matrixで変換するときに、引数でmode="tfidf"するだけです。

やってみます。


import pandas as pd
from tensorflow.keras.preprocessing.text import Tokenizer

# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

# データを準備
corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

keras_tokenizer = Tokenizer()
keras_tokenizer.fit_on_texts(corpus)

# 学習した単語のリストを作成(インデック0はpaddingした単語に予約されている)
word_list = [
        keras_tokenizer.index_word.get(i, "")
        for i in range(len(keras_tokenizer.index_word)+1)
    ]

df = pd.DataFrame(
        keras_tokenizer.texts_to_matrix(corpus, mode="tfidf"),
        columns=word_list
    )
print(df)
"""
      this     is    the  document  first  second    and  third    one
0    0.0  0.588  0.588  0.588     0.693  0.847   0.000  0.000  0.000  0.000
1    0.0  0.588  0.588  0.588     1.174  0.000   1.099  0.000  0.000  0.000
2    0.0  0.588  0.588  0.588     0.000  0.000   0.000  1.099  1.099  1.099
3    0.0  0.588  0.588  0.588     0.693  0.847   0.000  0.000  0.000  0.000
"""

出現する文書数とidfの対応は以下のようになっていそうです。
出現回数1回 ・・・ idf 1.099
出現回数2回 ・・・ idf 0.847
出現回数3回 ・・・ idf 0.693
出現回数4回 ・・・ idf 0.588

このようになる数式を探してみたところ、
$$
\text{idf}_{t} = \log{\left(1+\frac{\text{総文書数}}{1+\text{単語tを含む文書数}}\right)}
$$
が当てはまるようです。

scikit-learnのデフォルトの定義である、
$$
\text{idf}_{t} = \log{\left(\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}\right)}+1
$$
と似てるけど微妙に違いますね。

検算します。


for n in range(1, 5):
    print(np.log(1+4/(n+1)))

"""
1.0986122886681098
0.8472978603872034
0.6931471805599453
0.5877866649021191
"""

さて、idfがわかったので次はtdです。
tdに着目しやすくするために、単語1種類だけにして、出現回数だけ変えたコーパスで実験します。


corpus_2 = [
    "document",
    "document document",
    "document document document",
    "document document document document",
]

df = pd.DataFrame(
        keras_tokenizer.texts_to_matrix(corpus_2, mode="tfidf"),
        columns=word_list
    )
print(df)
"""
     this   is  the  document  first  second  and  third  one
0    0.0   0.0  0.0  0.0     0.693    0.0     0.0  0.0    0.0  0.0
1    0.0   0.0  0.0  0.0     1.174    0.0     0.0  0.0    0.0  0.0
2    0.0   0.0  0.0  0.0     1.455    0.0     0.0  0.0    0.0  0.0
3    0.0   0.0  0.0  0.0     1.654    0.0     0.0  0.0    0.0  0.0
"""

ifidfの値が、出現回数1回の場合(=idf)の何倍になっているかみます。


print(df["document"]/df["document"].iloc[0])
"""
0    1.000
1    1.693
2    2.099
3    2.386
Name: document, dtype: float64
"""

どうやら、
$$
\text{tf}_{t,d} = 1+\log{\text{文書d中の単語tの出現回数}}
$$
のようです。

検算がこちら。


for n in range(1, 5):
    print(1+np.log(n))

"""
1.0
1.6931471805599454
2.09861228866811
2.386294361119891
"""

これで、実験的にkerasにおけるtfidfの定義がわかりました。

あとはドキュメントと突き合わせて確認したかったのですが、冒頭に書いた通りドキュメントには記載がありません。
と言うことで、ソースコードを直接みてみます。

どうやらこの部分が該当するようです。
参考: keras-preprocessing/text.py at master · keras-team/keras-preprocessing · GitHub


                elif mode == 'tfidf':
                    # Use weighting scheme 2 in
                    # https://en.wikipedia.org/wiki/Tf%E2%80%93idf
                    tf = 1 + np.log(c)
                    idf = np.log(1 + self.document_count /
                                 (1 + self.index_docs.get(j, 0)))
                    x[i][j] = tf * idf

ここまで実験的に導いてきた結論と一致しますね。
ハードコーディングされているのでscikit-learnのような細かなオプションはなさそうです。

TfidfVectorizerのtfについて

前回の記事が、TfidfVectorizerのidfにフォーカスしたので、今回はtfの方を取り上げます。

以前の記事で書いた通り、一般的なtdの定義(Wikipedia日本語版の定義)では、tfはその単語の文書中の出現頻度です。
$$
\text{tf}_{t,d} = \frac{\text{文書d中の単語tの出現回数}}{\text{文書dの全ての単語の出現回数の和}}
$$
しかし、TfidfVectorizerにおいては、単純に出現回数が採用されています。
$$
\text{tf}_{t,d} = \text{文書d中の単語tの出現回数}
$$

これは、TfidfVectorizerでは通常の設定(norm=’l2′)では文書ベクトルは最後に長さ1になるように正規化するので、
tfを定数倍しても結果が変わらず、無駄な操作になるからだと考えられます。

とりあえず、norm=Falseを指定して、正規化せずにtfがただの出現回数になっていることを見ていきましょう。
サンプルのコーパスで学習して、idfとtfidfを出してみました。
出現回数が1回の単語はidf=tfidfとなり、2回の単語は、idf*2=tdidfとなっているのがみて取れます。


# %%pycodestyle
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

# データを準備しておく
corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
)
tfidf_model.fit(corpus)

tfidf = tfidf_model.transform(corpus)

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf.round(3))
"""
and 1.916
document 1.223
first 1.511
is 1.0
one 1.916
second 1.916
the 1.0
third 1.916
this 1.0
"""

# tfidfの値
print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
1  0.000     2.446  0.000  1.0  0.000   1.916  1.0  0.000   1.0
2  1.916     0.000  0.000  1.0  1.916   0.000  1.0  1.916   1.0
3  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
"""

もし本来の定義で使いたいのであれば、それ専用のオプションは用意されていないので、
各文のベクトルをそれぞれの文の単語数で割ってあげる必要があります。
あまり綺麗な書き方が思いつかなかったのですが、実装するとしたら次のようなコードになるでしょうかでしょうか。
途中のif分は元の文が空だったときに0除算を発生させないためのものです。


for i in range(len(corpus)):
    word_count = len(corpus[i].split(" "))
    if word_count > 0:
        tfidf[i] = tfidf[i] / word_count

# tfidfの値
print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first     is    one  second    the  third   this
0  0.000     0.245  0.302  0.200  0.000   0.000  0.200  0.000  0.200
1  0.000     0.408  0.000  0.167  0.000   0.319  0.167  0.000  0.167
2  0.319     0.000  0.000  0.167  0.319   0.000  0.167  0.319  0.167
3  0.000     0.245  0.302  0.200  0.000   0.000  0.200  0.000  0.200
"""

TfidfVectorizerのインスタンスを作るときに、use_id = False と指定すると、idfが1で統一されるので、tfの結果だけ確認できます。
要はCountVectorizerと似た挙動になりますね。(CountVectorizerは正規化しませんが)


tf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    use_idf=False,
)
tf_model.fit(corpus)

tf = tf_model.transform(corpus)
print(pd.DataFrame(tf.toarray(), columns=tf_model.get_feature_names()))
"""
   and  document  first   is  one  second  the  third  this
0  0.0       1.0    1.0  1.0  0.0     0.0  1.0    0.0   1.0
1  0.0       2.0    0.0  1.0  0.0     1.0  1.0    0.0   1.0
2  1.0       0.0    0.0  1.0  1.0     0.0  1.0    1.0   1.0
3  0.0       1.0    1.0  1.0  0.0     0.0  1.0    0.0   1.0
"""

さて、tfの値ですが、全ての単語数で割る一般的な定義を実現するオプションはなくてもそれ以外の亜種のオブションはあります。
参考: sklearn.feature_extraction.text.TfidfVectorizer

わかりやすくみるために、norm=False, usr_idf=False で試していきましょう。
まず、binaryと言う引数をTrueにすると、出現回数ではなく、出現する(1)か出現しない(0)かの2値になります。


tf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    use_idf=False,
    binary=True,
)
tf_model.fit(corpus)

tf = tf_model.transform(corpus)
print(pd.DataFrame(tf.toarray(), columns=tf_model.get_feature_names()))
"""
   and  document  first   is  one  second  the  third  this
0  0.0       1.0    1.0  1.0  0.0     0.0  1.0    0.0   1.0
1  0.0       1.0    0.0  1.0  0.0     1.0  1.0    0.0   1.0
2  1.0       0.0    0.0  1.0  1.0     0.0  1.0    1.0   1.0
3  0.0       1.0    1.0  1.0  0.0     0.0  1.0    0.0   1.0
"""

次に、 sublinear_tfという引数にTrueを渡すと、tfが、$1+\log(\text{tf})$に置き換えられます。
$1+\log(2)=1.693\dots$に注意して結果を見てください。


tf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    use_idf=False,
    sublinear_tf=True
)
tf_model.fit(corpus)

tf = tf_model.transform(corpus)
print(pd.DataFrame(tf.toarray(), columns=tf_model.get_feature_names()))
"""
   and  document  first   is  one  second  the  third  this
0  0.0     1.000    1.0  1.0  0.0     0.0  1.0    0.0   1.0
1  0.0     1.693    0.0  1.0  0.0     1.0  1.0    0.0   1.0
2  1.0     0.000    0.0  1.0  1.0     0.0  1.0    1.0   1.0
3  0.0     1.000    1.0  1.0  0.0     0.0  1.0    0.0   1.0
"""

英語版のWikipediaにtfの亜種がいろいろ紹介されていますが、その中にもない珍しいタイプの定義です。
$\log(1+\text{tf})$ならあるのですが。
参考: tf–idf – Wikipedia (English)

一つの文章の中に同じ単語が極端に繰り返し出現した場合などに、その影響を抑えられる良い形式だと思います。

TfidfVectorizerのidfについて

この記事では、scikit-learnのTfidfVectorizerの、idf部分について掘り下げてみていきます。
以前の記事でも書いた通り、デフォルトでのidfの挙動は一般的な定義とは異なります。
参考: tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

単語$t$についてみていくと、通常の定義は
$$
\text{idf}_{t} = \log{\frac{\text{総文書数}}{\text{単語tを含む文書数}}}
$$
であり、
TfidfVectorizer のデフォルトのオプションでは、
$$
\text{idf}_{t} = \log{\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}}+1
$$
となっています。

まず、実際にこの数式通り動いていることを見ておきましょう。


import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer

# データを準備しておく
corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
)
tfidf_model.fit(corpus)

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)

"""
and 1.916290731874155
document 1.2231435513142097
first 1.5108256237659907
is 1.0
one 1.916290731874155
second 1.916290731874155
the 1.0
third 1.916290731874155
this 1.0
"""

# 出現回数から計算したidfの値
for i in range(1, 5):
    print(f"出現回数{i}回, idf: ", np.log((1+4)/(1+i))+1)
"""
出現回数1回, idf:  1.916290731874155
出現回数2回, idf:  1.5108256237659907
出現回数3回, idf:  1.2231435513142097
出現回数4回, idf:  1.0
"""

document は3文書に登場しているから、idfは 1.2231435513142097、firtstは2文書に登場しているから、idfは1.5108256237659907と言うふうに、
scikit-learnのドキュメント通りに計算されていることがわかりますね。

さて、scikit-learnの定義がデフォルトと異なっている理由は、次の2つがあります。
(1) コーパス中に登場しない単語で0除算が発生しないように、log中の分数の分子と分母に1を足す。
(2) コーパス中の全ての文書に登場した単語のidfが0にならないように全ての単語のidfに1を足す。

(1)の方は平滑化と呼ばれる操作です。
実はTfidfVectorizerは、学習する単語をコーパスから自動的にピックアップするのではなく、モデルの引数として渡すことができます。
次のコード例では、コーパスに登場しない、oovという単語を明示的に加えてみました。
結果、$\log((1+4)/(1+0))+1 = 2.6094379124341005$ がoovのidf値になっていることがわかります。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    vocabulary=['and', 'document', 'first', 'is',
                'one', 'second', 'the', 'third', 'this', "oov"],
)
tfidf_model.fit(corpus)

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)
"""
and 1.916290731874155
document 1.2231435513142097
first 1.5108256237659907
is 1.0
one 1.916290731874155
second 1.916290731874155
the 1.0
third 1.916290731874155
this 1.0
oov 2.6094379124341005
"""

この分子と分母の+1については、smooth_idfと言う引数にFalseを渡すことで使わないようにもできます。
参考: sklearn.feature_extraction.text.TfidfVectorizer

この場合、vocabulary引数で、コーパスに登場しない単語が渡されていると、0除算のワーニングが発生し、該当単語のidf値はinfになります。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    vocabulary=['and', 'document', 'first', 'is',
                'one', 'second', 'the', 'third', 'this', "oov"],
    smooth_idf=False,
)
tfidf_model.fit(corpus)
"""
RuntimeWarning: divide by zero encountered in true_divide
  idf = np.log(n_samples / df) + 1
"""

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)
"""
and 2.386294361119891
document 1.2876820724517808
first 1.6931471805599454
is 1.0
one 2.386294361119891
second 2.386294361119891
the 1.0
third 2.386294361119891
this 1.0
oov inf
"""

tfidfの本来の定義に近い値で使う場合は、smooth_idf=Falseを指定するべきですが、この場合は安全のため、vocabularyは指定せず、
コーパスから自動的に学習するのに任せるべきでしょう。
(僕の場合は、vocabulary引数を使うことはほとんどありません。)

次に、(2)の全てのコーパスに倒叙する単語のidfが0にならないように、全部単語のidfに1足されている部分についてです。
こちらについては、これをオフにする引数のようなものは実装されていなさそうです。

どうしてもtfidfの本来の定義で使いたいんだ、と言う場合は、かなり無理矢理な操作になりますが、
モデルが学習したidfの値(idf_プロパティに格納されている)から1を引いてしまう手があります。
(当然、サポートされている操作ではないので実行は自己責任でお願いします。)

transform する際には、idf_ の値が使われるので次のようになります。


import pandas as pd
# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    smooth_idf=False,
)
tfidf_model.fit(corpus)
tfidf = tfidf_model.transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     1.288  1.693  1.0  0.000   0.000  1.0  0.000   1.0
1  0.000     2.575  0.000  1.0  0.000   2.386  1.0  0.000   1.0
2  2.386     0.000  0.000  1.0  2.386   0.000  1.0  2.386   1.0
3  0.000     1.288  1.693  1.0  0.000   0.000  1.0  0.000   1.0
"""

# idf_ に足されている1を引く 
tfidf_model.idf_ -= 1
tfidf = tfidf_model.transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     0.288  0.693  0.0  0.000   0.000  0.0  0.000   0.0
1  0.000     0.575  0.000  0.0  0.000   1.386  0.0  0.000   0.0
2  1.386     0.000  0.000  0.0  1.386   0.000  0.0  1.386   0.0
3  0.000     0.288  0.693  0.0  0.000   0.000  0.0  0.000   0.0
"""

コーパスの全ての文書に含まれていた、 is, the, this の idf値とtfidf値が0になっていることが確認できます。
また、他の単語についても、出現回数分値が落ちているのがみて取れます。

文書をTfidfVectorizerでベクトル化したときの正規化について

scikit-learnのtfidfについての記事の2本目です。
今回は結果のベクトルの正規化について紹介していきます。
前の記事でも書きましたが、TfidfVectorizerはデフォルトでは結果のベクトルを長さが1になるように正規化します。
参考: tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

ドキュメントのsklearn.feature_extraction.text.TfidfVectorizerのページの norm の説明にある通りです。

一応実験しておきます。サンプルの文章はUser Guideのページから拝借しました。


import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer

# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='l2',  # デフォルト
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first     is    one  second    the  third   this
0  0.000     0.470   0.58  0.384  0.000   0.000  0.384  0.000  0.384
1  0.000     0.688   0.00  0.281  0.000   0.539  0.281  0.000  0.281
2  0.512     0.000   0.00  0.267  0.512   0.000  0.267  0.512  0.267
3  0.000     0.470   0.58  0.384  0.000   0.000  0.384  0.000  0.384
"""

# ベクトルの長さが1であることの確認
print((tfidf.toarray()**2).sum(axis=1)**0.5)
# [1. 1. 1. 1.]

さて、ドキュメントにnorm{‘l1’, ‘l2’}, default=’l2’とある通り、norm='l1'と指定することもできます。
そうすると、ベクトルの長さを1にする代わりに、各要素の絶対値の和が1になるように正規化してくれます。
これも実験しておきましょう。なお、tfidfの結果は元々各成分がプラスなので、絶対値を取る処理は省略しています。
厳密にやるのであれば和をとる前にnp.abs()にかけましょう。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='l1',
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first     is    one  second    the  third   this
0  0.000     0.213  0.263  0.174  0.000    0.00  0.174  0.000  0.174
1  0.000     0.332  0.000  0.136  0.000    0.26  0.136  0.000  0.136
2  0.219     0.000  0.000  0.114  0.219    0.00  0.114  0.219  0.114
3  0.000     0.213  0.263  0.174  0.000    0.00  0.174  0.000  0.174
"""

# ベクトルの要素の和が1であることを確認
print(tfidf.toarray().sum(axis=1))
# [1. 1. 1. 1.]

ドキュメントでは、normには'l1'と'l2'の値しかサポートされていなように書いてありますが、実は他にも指定できる文字があります。
と言うのもGithubのソースコード(この記事を書いている時点ではバージョンは0.24.0)では、次のように実装されているからです。
参考: 該当箇所


        if self.norm:
            X = normalize(X, norm=self.norm, copy=False)

sklearn.preprocessing.normalize が呼び出されてます。
そして、そのドキュメントをみると、'l1','l2'に加えて'max'も対応しています。
'max'は、一番絶対値が大きい要素の絶対値が1になるように、要するに全部の要素の値が-1から1の範囲に収まるように、そして絶対値が一番大きい成分の値が±1になるように正規化してくれます。
ドキュメントに無い使い方なので、ご利用される場合は自己責任でお願いしたいのですが、次のコードの通り問題なく動作します。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='max',
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
   and  document  first     is  one  second    the  third   this
0  0.0      0.81    1.0  0.662  0.0   0.000  0.662    0.0  0.662
1  0.0      1.00    0.0  0.409  0.0   0.783  0.409    0.0  0.409
2  1.0      0.00    0.0  0.522  1.0   0.000  0.522    1.0  0.522
3  0.0      0.81    1.0  0.662  0.0   0.000  0.662    0.0  0.662
"""

また、もう一つ、これもドキュメントには無いのですが、正規化しないようにすることも可能です。
if self.norm:
とif文で分岐していますので、ここでFalseと判定される値を入れておけば大丈夫です。
FalseやNoneを入れておけば正規化されません。
(空白文字列でも、空配列でも、0でもお好きな値を使えますが、あまりトリッキーなことをしても意味はないのでFalseで良いでしょう)


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False, 
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
1  0.000     2.446  0.000  1.0  0.000   1.916  1.0  0.000   1.0
2  1.916     0.000  0.000  1.0  1.916   0.000  1.0  1.916   1.0
3  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
"""

次の記事からtfとidfの定義に関するオプションをいじって挙動を見ていくのですが、
その際は違いを見やすくするために正規化は行わないで試していきます。

tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

テキストをベクトル化するときに用いる基本的な方法の一つであるtf-idfについてこれから数回の更新でまとめていこうと思います。
最初にこの記事では一般的な定義と、scikit-learn(TfidfVectorizer)で引数を特に指定しなかった場合に実行されるデフォルトの定義について紹介し、
今後の記事でそのバリエーションとして、オプションを変えるとどのような定義で計算できるのかを紹介していく予定です。

さて、一言にtf-idfといってもその定義には非常に多くのバリエーションがあります。
英語版のWikipediaを見ていただくとそれらが紹介されています。
参考: tf–idf – Wikipedia (English)

一方で日本語版(この記事執筆時点)のWikipediaではそのうちの1個が紹介されています。
一般的にはこれを指すことが多そうなので、この記事ではこれを一般的な定義と呼びましょう。
参考: tf-idf – Wikipedia (日本語)

早速一般的な定義について紹介していきます。
文書$d$のtf-idfは、コーパス中の単語数に等しい長さのベクトルです。
そのベクトル中の単語$t$に対応する成分$\text{tfidf}_{t,d}$は次のように単語の出現頻度(Term Frequency)と、逆文書頻度(Inverse Document Frequency)の積として計算されます。
$$
\text{tfidf}_{t,d} = \text{tf}_{t,d}\cdot \text{idf}_{t}
$$
単語の出現頻度の定義は次のようになります。
$$
\text{tf}_{t,d} = \frac{\text{文書d中の単語tの出現回数}}{\text{文書dの全ての単語の出現回数の和}}
$$
そして、逆文書頻度の定義は次のようになっています。
$$
\text{idf}_{t} = \log{\frac{\text{総文書数}}{\text{単語tを含む文書数}}}
$$

ある文書に登場する単語は、その文書に多く登場するほど、tfが高まり、コーパス全体で珍しいほどidfが高まって、結果的にtfidfが高くなるようになります。

さて、ここまでが一般的なtfidfの定義の話でした。
普段、文書をtfidfでベクトル化するときは、僕はsciki-learnのTfidfVectorizerを使うことが多いです。
そのため、重要なのはTfidfVectorizerでどのように実装されているのかです。

User Guide をみると、tf, idfそれぞれ一般的なものとは違う設計になっていることがわかります。
参考: 6.2.3. Text feature extraction

まず、一番大きな特徴として、TfidfVectorizer(デフォルト設定)でベクトル化した文書はベクトルの長さが1になるように正則化されます。
これは単純に、tfidfベクトルに変換した後に、結果のベクトルをそれ自体の長さで割って返す実装になっています。

次に、tf,idfの定義がそれぞれ一般的なものと違います。
tf は、単語の出現頻度ではなく、単純に出現回数になっています。

$$
\text{tf}_{t,d} = \text{文書d中の単語tの出現回数}
$$

そして、idfについては、$\log$の中の分数の分子と分母にそれぞれ$1$を足す平滑化処理が行われており、さらに、最後に$1$が足されています。
つまり、TfidfVectorizerのidfは次の式で計算されます。
$$
\text{idf}_{t} = \log{\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}}+1
$$

これらの3つの特徴と、オプションを変えた場合の挙動について今後の記事で紹介していきたいと思います。
– ベクトルの正規化
– tfの定義の違い
– idfの定義の違い