蛇ノ目の記

技術のことも。そうでないことも。

アイドルで理解するTF-IDF TF-IDF編

ども

そろそろPyCon JP 2019が始まりますね

始まると一気にブログを書かなくなるので今のうちに書きます

PyCon JPのお祭り感が好きなんですよね

そのあとはPyCon TWに遊びに行きます

人生二度目の台湾です

TL; DR

  • TF は文書頻度。

    • 文書中の単語の重要度を表す指標
  • IDFは逆文書頻度

    • 各単語がどれだけの文書に出現するかを表す指標
  • TF-IDFはTFとIDFの積。それぞれの文書の中で重要な単語ほど大きい値になる

TFの計算式

文書d_iにおける単語t_jtfは以下のように表される。

tf_i=\frac{n_i,j}{\sum_k n_{k,j}}

n_{i,j}は文書d_iにおける単語t_jの出現回数。

{\sum_k n_{k,j}}は文書d_iにおけるすべての単語の出現回数の和。

tf-idf - Wikipedia

式で表しても(自分が)今ひとつわからないので実際の文書で考えてみる。

神﨑風花はかわいい

寺口夏花はかわいい

山崎愛はかわいい

みんなかわいい

という文書があって、単語は

  • 神﨑風花

  • 寺口夏花

  • 山崎愛

  • みんな

  • かわいい

と分けるとする。

文書中の単語はベクトルで表す。ここで単語は6つなのでベクトルの成分は6つになる。

神﨑風花はかわいい

の各単語のTFを求めてみる。

単語のベクトルはこのようになる。

神﨑風花 寺口夏花 山崎愛 みんな かわいい
1 0 0 0 1 1

「神﨑風花」という単語のtfは以下のように求められる。

{\sum_k n_{k,j}}は文書d_iにおけるすべての単語の出現回数の和なので、3になる。

tf_i=\frac{n_i,j}{\sum_k n_{k,j}}=\frac{1}{3}=0.3

同様に他の単語についてもtfを求めると、

神﨑風花はかわいい

の各単語のTFは以下のようになる。

神﨑風花 寺口夏花 山崎愛 みんな かわいい
0.3 0 0 0 0.3 0.3

IDF

文書d_iにおける単語t_jidf_iは以下のように表される。

|D|は文書の総数。

|\lbrace d:d \ni t_i \rbrace |は単語t_iを含む文書数。

tdf_i=\log\frac{|D|}{|\lbrace d:d \ni t_i \rbrace |}

tf-idf - Wikipedia

実際には単語tが出現しない文書があるとゼロ除算が発生するので、分子と分母にそれぞれ1を加える。

やっぱり式で表しても(自分が)今ひとつわからないので実際の文書で考えてみる。

神﨑風花はかわいい

寺口夏花はかわいい

山崎愛はかわいい

みんなかわいい

があって、単語のベクトルはそれぞれ以下のようになる。

神﨑風花 寺口夏花 山崎愛 みんな かわいい
1 0 0 0 1 1
0 1 0 0 1 1
0 0 1 0 1 1
0 0 0 1 0 1

単語それぞれのIDFを求めてみる。

  • 神﨑風花

idf_{神﨑風花}=\log_2\frac{4}{1}=\log_2 4=2.0

  • 寺口夏花

idf_{寺口夏花}=\log_2\frac{4}{1}=\log_2 4=2.0

  • 山崎愛

idf_{山崎愛}=\log_2\frac{4}{1}=\log_2 4=2.0

  • みんな

idf_{みんな}=\log_2\frac{4}{1}=\log_2 4=2.0

idf_{は}=\log_2\frac{4}{3}=0.4

※ 少数第二位で四捨五入してる

  • かわいい

idf_{かわいい}=\log_2\frac{4}{4}=0

神﨑風花 寺口夏花 山崎愛 みんな かわいい
2.0 2.0 2.0 2.0 0.4 0

TF-IDF

TF-IDFはtf_i,jidf_iの積。

「神﨑風花はかわいい」の場合

tf_{i,j}

神﨑風花 寺口夏花 山崎愛 みんな かわいい
0.3 0 0 0 0.3 0.3

idf_i

神﨑風花 寺口夏花 山崎愛 みんな かわいい
2.0 2.0 2.0 2.0 0.4 0

ifidf_{i,j}

神﨑風花 寺口夏花 山崎愛 みんな かわいい
0.6 0 0 0 0.12 0

となる。最も値が大きいのは「神﨑風花」なので、文書「神﨑風花はかわいい」の中で重要度が最も高いのは「神﨑風花」という単語ということになる。

Pythonでやってみる

scikit-learnを使えば簡単にできると「Pythonによるあたらしいデータ分析の教科書」という本に書いてあるけど、今回はNumPyを使って自力で実装してみる。

なお、TF-IDFのL2正規化はNumPyの力を借りた。

import typing

import numpy as np

def get_all_docs(in_csv: str) -> typing.List:
    all_docs = []
    with open(in_csv, encoding='utf-8') as fin:
        for line in fin:
            words = [word.strip() for word in line.split(',')[3:]]
            all_docs.append(words)
    return all_docs


def create_words_list(all_words: typing.List) -> typing.List:
    flatten_words = []
    for words in all_words:
        for word in words:
            flatten_words.append(word)
    words_list = []
    for word in flatten_words:
        if word not in words_list:
            words_list.append(word)
    return words_list


def get_word2int(words_list: typing.List) -> typing.Dict[str, int]:
    word2int = {}
    for idx, word in enumerate(words_list):
        word2int[word] = idx
    return word2int


def calc_tf(all_docs: typing.List, word2int: typing.Dict[str, int]) -> np.ndarray:
    tf_array = np.zeros((len(all_docs), len(word2int)))
    for doc_idx, doc in enumerate(all_docs):
        temp_array = np.zeros((1, len(word2int)))
        for word_idx, word in enumerate(word2int.keys()):
            if word in doc:
                temp_array[0, word_idx] += 1
        tf_array[doc_idx:] = temp_array / np.sum(temp_array)
    return tf_array


def calc_idf(all_docs: typing.List, word2int: typing.Dict[str, int]) -> np.ndarray:
    temp_array = np.zeros((1, len(word2int)))
    all_docs = all_docs
    for idx, word in enumerate(word2int.keys()):
        word_count = 0
        for doc in all_docs:
            if word in doc:
                word_count += 1
        temp_array[0, idx] = word_count
    idf_array = np.log2((len(all_docs) + 1 / temp_array + 1))
    return idf_array


def l2_normalize(tf_idf: np.ndarray) -> np.ndarray:
    l2_norm = np.linalg.norm(tf_idf, ord=2)
    return tf_idf / l2_norm

def tf_idf(in_csv: str):
    all_docs = get_all_docs(in_csv)   # in_csvは形態素解析して取り出した名詞のCSVファイル
    words_list = create_words_list(all_docs)
    word2int = get_word2int(words_list)
    tf_array = calc_tf(all_docs, word2int)
    idf_array = calc_idf(all_docs, word2int)
    tf_idf = tf_array * idf_array
    normalized_tf_idf = l2_normalize(tf_idf)

tf_idf_with_idol/tf_idf.py at master · NaoY-2501/tf_idf_with_idol · GitHubから抜粋。

次回は山崎愛ちゃんのブログに対して計算したTF-IDFと実際の記事を見比べて、特徴語が取り出せているかを検証したりする予定。

アイドルで理解するTF-IDF 形態素解析編

ども

昨日はstapy #49でしたね

LT聴いたくれた方ありがとうございます。

アイドルで理解するTF-IDF 形態素解析編です

TF-IDFまであと一歩です

別に記事を増やしたくて細かく分けてるわけじゃないです

なにごとも一歩ずつ進めていくのがいいと思うんです

知らんけど

TL;DR

  • NEologdを使うときはTaggerに辞書のパスを指定する

  • MeCab.Nodeオブジェクトはiterableじゃない

  • 形態素の詳細な情報はMeCab.Tagger.parseToNode.featureで取得できる

    • 次の形態素MeCab.Tagger.parseToNode.nextで取得する

形態素解析する

mecab-python3を使う。

pypi.org

今回のコードはこれだけ。

import csv

import MeCab


def analysis(in_csv: str, out_csv: str):
    tagger = MeCab.Tagger('-d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd')

    with open(out_csv, 'w', encoding='utf-8') as fout:
        with open(in_csv, encoding='utf-8') as fin:
            reader = csv.DictReader(fin)
            for row in reader:
                lemmas = []
                node = tagger.parseToNode(row['body'])
                while node:
                    result = node.feature.split(",")
                    pos = result[0]
                    lemma = result[-3]
                    if pos == '名詞':
                        lemmas.append(lemma)
                    node = node.next
                row = f"{row['date']},{row['url']},{row['title']},{','.join(lemmas)}\n"
                fout.write(row)

tf_idf_with_idol/morphological_analysis.py at master · NaoY-2501/tf_idf_with_idol · GitHub

MeCab: Yet Another Part-of-Speech and Morphological Analyzerスクリプト言語のバインディングにおおよその使い方が書いてある。

MeCab::Tagger というクラスのインスタンスを生成し, parse (もしくは parseToString) というメソッドを呼ぶことで, 解析結果が文字列として取得できます.

これはPerlの例で書いているけど、Pythonであれば「MeCab.Taggerというクラスのインスタンスを作成し〜」となる。 また、Taggerクラスの引数にオプションを指定できる。使用する辞書を指定するときは-dオプションの後に辞書のパスを書く。今回は前回、DockerイメージにインストールしたNEologdの辞書のパスを指定する。

表層系や形態素など詳しい情報を取りたい場合はMeCab.Tagger.parseToNode()メソッドを使う。 parseToNode()メソッドは文章を渡されると、'MeCab.Node'オブジェクトを返す。

MeCab::Node は, 双方向リストとして表現されており, next, prev というメン バ変数があります.

スクリプト言語のバインディング

Nodeオブジェクトはiterableではないので、for文で回そうとすると怒られる。

Traceback (most recent call last):
File "tf_idf_with_idol.py", line 13, in <module>
for n in node:
TypeError: 'MeCab.Node' object is not iterable

スクリプト言語のバインディング にあるようにwhile文を使って繰り返し処理しよう。

node<Swig Object of type 'MeCab::Node *' at 0x7fe6a4fae960>というようなオブジェクトになっている。 詳細な情報を取得するときはfeature属性を使う。 こんな感じの文字列が取得できる。

 名詞,一般,*,*,*,*,迷路,メイロ,メイロ

カンマ区切りの文字列なので、 split()メソッドを使ってリストにすると使いやすくなる。品詞は見てわかるように先頭にある。

なんとなくMeCabの使い方がわかったところで「広告の街」の歌詞の一部を形態素解析して終わりにする。

www.youtube.com

 BOS/EOS,*,*,*,*,*,*,*,*
 名詞,一般,*,*,*,*,迷路,メイロ,メイロ
 助詞,連体化,*,*,*,*,の,ノ,ノ
 名詞,非自立,助動詞語幹,*,*,*,様,ヨウ,ヨー
 助動詞,*,*,*,特殊・ダ,体言接続,だ,ナ,ナ
 名詞,一般,*,*,*,*,恋,コイ,コイ
 助詞,格助詞,一般,*,*,*,に,ニ,ニ
 動詞,自立,*,*,一段,連用形,落ちる,オチ,オチ
 助詞,接続助詞,*,*,*,*,て,テ,テ
 動詞,非自立,*,*,五段・カ行促音便,基本形,いく,イク,イク
 名詞,一般,*,*,*,*,心,ココロ,ココロ
 助詞,並立助詞,*,*,*,*,と,ト,ト
 名詞,一般,*,*,*,*,裏腹,ウラハラ,ウラハラ
 名詞,一般,*,*,*,*,ゲーム,ゲーム,ゲーム
 助詞,連体化,*,*,*,*,の,ノ,ノ
 名詞,非自立,助動詞語幹,*,*,*,様,ヨウ,ヨー
 助詞,副詞化,*,*,*,*,に,ニ,ニ
 名詞,サ変接続,*,*,*,*,レベルアップ,レベルアップ,レベルアップ
 形容詞,自立,*,*,形容詞・アウオ段,連用テ接続,うまい,ウマク,ウマク
 助詞,係助詞,*,*,*,*,は,ハ,ワ
 動詞,自立,*,*,五段・カ行促音便,未然形,いく,イカ,イカ
 助動詞,*,*,*,特殊・ナイ,基本形,ない,ナイ,ナイ
 名詞,サ変接続,*,*,*,*,検索,ケンサク,ケンサク
 名詞,副詞可能,*,*,*,*,結果,ケッカ,ケッカ
 動詞,自立,*,*,一段,連用形,並べる,ナラベ,ナラベ
 助詞,接続助詞,*,*,*,*,て,テ,テ
 助詞,係助詞,*,*,*,*,も,モ,モ
 動詞,自立,*,*,一段,未然形,探せる,サガセ,サガセ
 助動詞,*,*,*,特殊・ナイ,基本形,ない,ナイ,ナイ
 名詞,一般,*,*,*,*,気持ち,キモチ,キモチ
 助詞,係助詞,*,*,*,*,は,ハ,ワ
 名詞,一般,*,*,*,*,迷路,メイロ,メイロ
 助詞,連体化,*,*,*,*,の,ノ,ノ
 名詞,一般,*,*,*,*,奥,オク,オク
 助詞,格助詞,一般,*,*,*,に,ニ,ニ
 動詞,自立,*,*,一段,連用形,消える,キエ,キエ
 助詞,接続助詞,*,*,*,*,て,テ,テ
 動詞,非自立,*,*,五段・カ行促音便,基本形,いく,イク,イク
 名詞,代名詞,一般,*,*,*,あなた,アナタ,アナタ
 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
 動詞,自立,*,*,五段・サ行,基本形,探す,サガス,サガス
 BOS/EOS,*,*,*,*,*,*,*,*

照井さんたちによる演奏動画もめちゃめちゃかっこいいので観てください。

www.youtube.com

次回はいよいよTF-IDF編の予定です。PyCon JPが始まる前に書かないと絶対フェードアウトするのでがんばるぞい。

アイドルで理解するTF-IDF 環境構築編

ども

台風やばかったですね

雨と風が強すぎてぜんぜん寝れませんでした

うちの近くに去年の台風で傾いた建物があるんですがそれはなんか無事でした

どうでもいいですね

はい

アイドルで理解するTF-IDF第2回です

まだTF-IDFまでたどり着きません

TL;DR

  • Dockerを使って形態素解析&TF-IDF計算の環境作った

  • Ubuntu 18.04 + Python3.7 + MeCab + NEologd

    • NEologdの辞書のPATHは必ずチェックしよう
  • docker-composeで形態素解析とTF-IDFの計算を実行するようにした

Dockerイメージを作る

Python3についてはPython3.7を使いたかったのでソースからビルドした。

MeCabとNEologdのインストールについてはMeCab用のDockerfile - sanshonokiの日記 を参考にDockerfileを書いた。

イメージの役割は形態素解析とTF-IDFの計算のみで、IPAフォントのインストールは不要と判断して削除した。Maplotlibで日本語入ったグラフを出すとかだったら入れないとダメ。

NEologdをMeCabのデフォルト辞書に設定する箇所について、参考にしたブログでは以下のようになっていた。

RUN sed -i 's/dicdir = \/var\/lib\/mecab\/dic\/debian/dicdir = \/usr\/lib\/mecab\/dic\/mecab-ipadic-neologd/' /etc/mecabrc

/etc/mecabrcdicdirをNEologdの辞書のパスに置き換えているわけだけど、この置き換え先のパスが今回の環境では異なっていて、/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd となっていた。DockerイメージにNEologdをインストールしたら、一度そこでコンテナを実行して、NEologdの辞書のパスを確認するのがよさそう。

$ docker run -it イメージ名 /bin/bash
$ echo `mecab-config --dicdir`"/mecab-ipadic-neologd"

辞書のパス確認のコマンドはNEologdのREADMEに書かれている。

github.com

今になって思ったけど、sedを使って書き換えるではなく手元にdicdirを書き換えたmecabrcを用意してDockerイメージをビルドするときに置き換えるのもいいかもしれない。

$ docker run -it イメージ名 /bin/bash
# NEologdの辞書のパスを確認
$ echo `mecab-config --dicdir`"/mecab-ipadic-neologd"
# mecabrcを表示
$ cat /etc/mecabrc

ここで表示したmecabrcをコピーして手元で書き換える、とか。

とりあえず現状最新のDockerfile。

# ref. http://sanshonoki.hatenablog.com/entry/2018/10/09/231345
FROM ubuntu:18.04
RUN apt update \
    && apt install -y \
    build-essential \
    zlib1g-dev \
    libncurses5-dev \
    libgdbm-dev \
    libnss3-dev \
    libssl-dev \
    libreadline-dev \
    libffi-dev \
    wget \
    git \
    mecab \
    curl \
    libmecab-dev \
    mecab-ipadic-utf8\
    language-pack-ja \
    xz-utils \
    file \
    openssl \
    gawk \
    sudo \
    unzip \
    && apt clean \
    && update-locale LANG=ja_JP.UTF-8

# Set locale
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP.UTF-8
ENV LC_ALL ja_JP.UTF-8

# Install Python3.7
# ref. https://linuxize.com/post/how-to-install-python-3-7-on-ubuntu-18-04/
WORKDIR /usr/local/src
RUN wget https://www.python.org/ftp/python/3.7.3/Python-3.7.3.tar.xz \
  && tar -xf Python-3.7.3.tar.xz \
  && cd Python-3.7.3 \
  && ./configure --enable-optimization \
  && make \
  && make altinstall

# Install mecab-ipadic-NEologd
WORKDIR /opt
RUN git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git
WORKDIR /opt/mecab-ipadic-neologd
RUN ./bin/install-mecab-ipadic-neologd -n -y
RUN cd /opt
RUN rm -rf mecab-ipadic-neologd

# Set mecab-ipadic-NEologd as default
RUN sed -i 's/dicdir = \/var\/lib\/mecab\/dic\/debian/dicdir = \/usr\/lib\/x86_64-linux-gnu\/mecab\/dic\/mecab-ipadic-neologd/' /etc/mecabrc

# Make directory and Copy code
WORKDIR /home
RUN mkdir code
RUN mkdir output
WORKDIR /home/code
COPY code .

CMD python3.7 -V

mecab-pythonという名前をつけてDockerイメージをビルド。

docker build -t mecab-python .

docker-compose.ymlを作る

本当はDockerイメージをビルドする段階でパッケージをインストールしておきたかった。$ python3.7 -m pip install -r requirements_docker.txtでPython3.7のpipであることを明示的にしてインストールしたにもかかわらず、docker-composeでコンテナを開始するとそのパッケージが見つからなくなっていた(今回、原因まで調べられていない)。そのためコンテナ開始のタイミングでインストールしている。

version: '2'
services:
    mecab-python:
        build: .
        volumes:
            - ./code:/home/code
            - ./output:/home/output
        command: >
          /bin/bash -c
          "python3.7 -m pip install -U pip
          && python3.7 -m pip install -r requirements_docker.txt
          && python3.7 tf_idf_with_idol.py"

これで環境構築ができたので、次回は形態素解析とTF-IDFです。#stapyでのLTが終わってから書きます。

めっちゃどうでもいいし俺がやってもアレなだけなんだけど、このシリーズの冒頭は山崎愛ちゃん構文に寄せてます。

アイドルで理解するTF-IDF データ準備編 - アメブロを攻略する

ども。

一度もブログを書くことなく8月が過ぎてた。

もう9月ってやばくないですか。やばいです。

今回は9/11(水)のみんなのPython勉強会 #49でLTする予定の「アイドルで理解するTF-IDF」のデータ準備編。

TL;DR

今回やりたいこと

sora tob sakana official blogから記事のタイトル, 投稿日時, 記事本文をスクレイピングする。

スクレイピングした情報はCSVにまとめる。

ブログ記事の見た目はこんな感じ。

f:id:Nao_Y:20190907173221p:plain

※ 風花ちゃんのブログを例に出したけど今回は山崎愛ちゃんのブログ記事を対象にする。

ぱっと見、Requestsでレスポンスボディを取ってきてBeautifulSoupに喰わせてやればよさそうに見えるけど、実際にそれで取得できるのはタイトルと記事本文のみ。なぜ投稿日時がJavaScriptで描画されているのか。わからない。

山崎愛ちゃんのブログを選んだ理由

「基本的に1記事につき1つの話題」だから。TF-IDFで記事の特徴語を取ったときに、特徴語が記事のテーマと一致しているかの判断が簡単なように思えた。

あとオサカナのオタクとして、毎日更新されるのを楽しみにしてるから。愛ちゃんはここ数ヶ月毎日更新している。その原動力は知らんけど。

風花ちゃん最近割と更新が多いのでとても嬉しい。

ヘッドレスブラウザを導入する

ヘッドレスブラウザを使ってJavaScriptが実行された後のソースコードを使えばよさそう、ということでその方法を探す。今回はHeadless Chromeを使う。

ブラウザを実際に動かすということでまず必要になるのはSelenium。さくっと入れる。

$ pip install selenium

Chromeのドライバーのパスを指定してやる必要があるけど、chromedriver_binaryを使うとパスを通してくれる。便利かよ。

To use chromedriver just import chromedriver_binary. This will add the executable to your PATH so it will be found.

pypi.org

注意したいのは手元のChromeのバージョンに合ったchromedriver_binaryのバージョンをインストールする必要があるということ。

自分の手元では76.0.3809.132だったのでこれに合ったバージョンをインストールする。

$ pip install chromedriver_binary==76.0.3809.126.0

Chromeをヘッドレスモードで使うときはChromeのオプションに--headlessを指定する。

コードを書く

import chromedriver_binary # 手元の環境に合わせて76.0.3809.126.0を使う
from bs4 import BeautifulSoup as bs
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

def init_driver() -> webdriver.Chrome:
    options = Options()
    options.add_argument('--headless')
    driver = webdriver.Chrome(options=options)
    return driver


def get_soup(driver: webdriver.Chrome, url: str) -> bs:
    driver.get(url)
    html = driver.page_source
    return bs(html, 'html.parser')

def main():
    # 2019/09/06の山崎愛ちゃんの記事
    url = 'https://ameblo.jp/soratobsakana/entry-12521726090.html' 
    driver = init_driver()
    soup = get_soup(driver, url)

基本形としてはこれで以上。どうでもいいけど今回からType Hintingを導入してみた。

後はいい感じに前処理してCSVで保存する。

詳細は https://github.com/NaoY-2501/tf_idf_with_idol/blob/master/blog_scraper.py を参照のこと。

アイドルで理解するTF-IDFのリポジトリは以下。

github.com

↑を見られるとまだTF-IDFが実装できてないことがバレる。

次回は環境構築編を書く予定。

Notebookを見やすくするCSSを書いた

sora tob sakana東名阪ツアー大阪公演に遠征した@大阪

どういうわけかWiFiが入らない梅田のスタバで書いている。なにが悲しくてスタバでテザリングしないといけないのか…。

というのは置いておいて、近頃Colaboratoryが便利だなぁと思ってるんですが、どうにもフォントサイズが小さかったり行間が狭かったりしてコードが書きづらい、読みづらい。 そういうことを嘆いていたら、kashewさんからStylusを使うのがよさそうと助言を受けたのでChromeに入れてColaboratoryの見た目をいじることにした。kashew++。

chrome.google.com

というわけでCSS

gist.github.com

これを入れるとこうなるというのが以下のツイート。

ちなみにJupyter Notebookにも対応しているので、Colaboratory使ってないけどNotebookのコードが読みづらいなと感じてる人もこのCSSを試してみてくれよな。

iframeになっているアウトプット部分も調整しようと試行錯誤中。なにかいい方法あったら教えてください。

ダウンロードリンクからCSVをスクレイピングしてDataFrameにする - Spotify Charts編

少し前にこのブログでも取り上げたSpotify Charts。各国のヒットチャート・バイラルチャートが公開されている見ていて飽きないWebサービス

Spotify ChartsではチャートをCSV形式でダウンロードできる。そのCSVスクレイピングしてDataFrameにしてみる。ついでに順位の推移ごとにグルーピングして可視化めいたこともやってみた。

Notebook

今回のコードはColaboratoryで閲覧できるようにしている。Colaboratory便利かよ……。

colab.research.google.com

DataFrame作成

今回は日本のSpotify最新ヒットチャートのダウンロードリンク(https://spotifycharts.com/regional/jp/daily/latest/download)を扱う。Notebookの実行では2016/06/02のチャートになっている。

import io

import pandas as pd
import requests

from bs4 import BeautifulSoup as bs

res = requests.get('https://spotifycharts.com/regional/jp/daily/latest/download')

content = res.content

df = pd.read_csv(io.BytesIO(content))

バイト列で取得されるCSVの中身をBytesIOを使ってファイルオブジェクトの代わりにしている。この手法は以下のブログを参考にした。

www.haya-programming.com

1行目には Note that these figures are generated using a formula that protects against any artificial inflation of chart positions. というメッセージが入っているので、スキップする。

f:id:Nao_Y:20190604191620p:plain

df = pd.read_csv(io.BytesIO(content), skiprows=[0])

これでチャートのDataFrame化は完了。簡単。

f:id:Nao_Y:20190604191733p:plain

順位の推移をDataFrameに加える

ここからは作成したDataFrameに情報を足してゆく。

Spotify Chartsでは順位の推移を表す記号が表示されているが、この情報はCSVに含まれていないのでスクレイピングしてDataFrameに追加する。

記号はSVGで描画されているので、BeautifulSoupでHTMLをパースして順位の推移を取得する。

res = requests.get('https://spotifycharts.com/regional/jp/daily/latest/')
soup = bs(res.content)

svg_tags = soup.find_all('svg')

get_status関数でSVGタグのブロックから上昇、下降、維持、新規を判定する。 polygon属性の値などはブラウザのDeveloperToolでのぞき見た。

def get_status(svg):
  up = '0 9 12 9 6 3 '
  down = '12 3 0 3 6 9 '
  if svg.find('polygon'):
    polygon = svg.find('polygon')['points']
    if polygon == up:
      return 'up'
    elif polygon == down:
      return 'down'
  elif svg.find('rect'):
    return 'keep'
  elif svg.find('circle'):
    return 'new'

status = []
for svg in svg_tags:
  status.append(get_status(svg))

そしてDataFrameに列を追加。

df['status'] = status

f:id:Nao_Y:20190604192528p:plain

Spotify IDをDataFrameに追加

Simple-Spoitfyを使って曲の特徴情報を取得したくなるかもしれないので楽曲ごとに固有のSpotify IDをDataFrameに追加する。

df['Spotify ID'] = df['URL'].apply(lambda x: x.split('/')[-1])

f:id:Nao_Y:20190604192912p:plain

順位の推移をグルーピングして可視化してみる

順位の推移(status)の値でグルーピングして、推移の仕方による順位のQuantileをまとめる。

グルーピングしてからQuantileを求める手法は以下を参考にした。

stackoverflow.com

status_group = df.groupby(by='status')
positions = status_group['Position'].quantile([0.05, 0.25, 0.5, 0.75, 0.9]).unstack()

最後に推移ごと(上昇, 下降, 維持, 新規)による順位の分布を折れ線グラフにする。

positions.T.plot(figsize=(15, 10), title='Distribution of position change at 2019/06/02');

f:id:Nao_Y:20190604193827p:plain

雑可視化からなんとなくわかること。

  • 順位の維持は100位以下の順位では起きていない

  • ランキング初登場の曲は175~200位の間にしかランクインしていない

  • 初登場で上位に食い込む曲は無い

  • 順位の上昇・下降の約25%は20~70位内で起きている

Spotify Charts面白い

2017/01/01からのチャートが蓄積されているので、その時々の流行りや根強く聴かれている曲を見つけることができ面白い。 ここ半年くらいの日本のチャートを見ると、あいみょん人気がやばいことがわかる。聴いたことないけど。

チャートは2種類あり、TOP 200 はストリーミング数の蓄積によるランキング。一方、VIRAL 50SNSの分析で集計しているランキングらしい。 VIRAL 50の集計方法については以下のフォーラムで議論されている。

community.spotify.com

Spotify Web APIを使えば楽曲の特徴情報が得られるので、人気のある曲が持つ特徴を可視化することもできる。 そこで活きてくるのがSpotify Web APIPythonで使いやすくすることを目的にしているSimple-Spotify(ダイマ)。

PyCon mini Sapporo 2019で登壇した - Simple-Spotify: Pythonでシンプルに楽曲データを扱うライブラリ

5月11日に札幌で開催されたPyCon mini Sapporo 2019で登壇した。

写真撮る人がめっちゃ上手くて影とかいい感じになっててすごい。正装(オサカナTシャツ)もばっちり写っている。

テーマは3ヶ月くらい前から作っていたSpotify Web APIのラッパーライブラリSimple-Spotify。懇親会ではUKロック好きな@shinyorkeとSimple-Spotifyのいい感じな使い方の話で盛り上がった。興味を持ってもらえてとても嬉しかった。

github.com

developer.spotify.com

Spotify Web APIのラッパーライブラリとしてはSpotipyがあるが、requestsに依存しているそれとは異なりSimple-SpotifyはPure Pythonで実装している。 また、SpotipyがAPIのレスポンスを辞書形式で扱っているのに対してSimple-Spotifyではクラスとして扱っている。

Simple-SpotifyとSpotipyの違いを見比べてみる。ここからは発表にはない話になる。

Spotipy

import spotipy
sp = spotipy.Spotify()

results = sp.search(q='weezer', limit=20)
for i, t in enumerate(results['tracks']['items']):
    print ' ', i, t['name']

Simple-Spotify

from simple_spotify.api import Spotify
from simple_spotify.authorization import ClientCredentialsFlow

res = ClientCredentialsFlow.token_request(CLIENT ID, CLIENT_SECRET)
auth = ClientCredentialsFlow(**res)
sp = Spotify(auth)

results = sp.search(q='sora tob sakana', market='JP')
for album in results.albums.items:
    print(album.name)
sora tob sakana
World Fragment Tour
New Stranger
alight ep
cocoon ep
魔法の言葉
夜空を全部
アルファルド

sora tob sakanaはいいぞ。

Spotipyでは認証を行っていないんだけど、Web APIのドキュメントにはすべてのリクエストは認証が必要、と書いてあるのでサンプルコードが古い可能性がある。

Authentication

All requests to Web API require authentication.

https://developer.spotify.com/documentation/web-api/

認証を含めなければ行数はほぼ同じ。 検索結果(results)オブジェクトにアルバム(albums)属性があって、さらにそれぞれのアルバム(items)がある、というのはそこそこ直感的でないかと思っている。

認証について言えば、認証用のエンドポイントからのレスポンスを一度受け取ってから、それをアンパックして認証クラスに渡すというのはちょっとわかりにくい流れになってしまった。 本当のところを言えば、認証用クラスにClient IDとClient Secretを渡せばOKという風にしたかったのだけど、もう一つの認証をWebアプリ上で使えるようにする関係でそれと同じ流れにした。

これがFlaskで作成したSimple-Spotifyを使った簡単なWebアプリ。

@app.route('/')
def top():
    if 'response' in session.keys():
        auth = AuthorizationCodeFlow(**session['response'])
        sp = Spotify(auth)
        user = sp.get_current_user_profile()
        top_artists = sp.get_users_top('artists', time_range='medium_term')
        artists = top_artists.items
        top_tracks = sp.get_users_top('tracks', time_range='medium_term')
        tracks = top_tracks.items

        return render_template('index.html', name=user.display_name, artists=artists, tracks=tracks)
    else:
        seed = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
        url = access_authorize_page(CLIENT_ID, REDIRECT_URI, SCOPES, seed)
        return render_template('index.html', auth_url=url)


@app.route('/callback/')
def callback():
    """
    callback
    """
    session['response'] = AuthorizationCodeFlow.token_request(
        CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, request.args['code'])
    return redirect(url_for('top'))

Spotifyへのログインをしてからアクセストークンを受け取るのだけど、アクセストークンの取得をtop()で行ってしまうとリロードやページ遷移の度にトークンを取りに行こうとしてAPIからエラーが返ってきてしまった。これを避けるためにコールバックでアクセストークンなどを受け取って、セッションに格納。top()でそれを使うという流れにした。 これには認証方法によってSimple-Spotifyの使い方に違いが出てしまうのを避けたかったという思いがある。

と言い訳めいたことを書いてから、実際にSimple-Spotifyの使い方などを書きたいと思うのだけど寒いのでしばらくしてから続きを書く。 なにしろこんなところで書いているので風が冷たい。