自作自演。Dockerで簡単にセットアップして、スクレイピングし、多クラス分類をしてみる。


はじめに

Qiitaの投稿に慣れていきます。。

Dockerによる環境構築やAWSによる、「PCローカル環境依存」への脱却をしようとしているこの頃。
いろんな学習をしているうちに、ローカルにタコ足配線みたく崇高な設定だらけになって、
もっとシンプルにそして、PCの買い替えの時に困らないようにしたいよね〜という人も少なくないはず

Docker構築

コンテナ上で作業

スクレイピングとかでデータ収集

せっかくなので、多クラス分類

をしてみました。

全てgit上に上げているのと、今後gensimとか使って各評価の文章要約でもしようかと思っています。
また、その時に書きます。

https://github.com/Yu0130fri/Scraping-NLP

この投稿だけでDocker完璧!とか、多クラス分類できるようになる!
みたいな崇高な記事ではないことも先に書いておきます(^^;)

Dockerfileによる環境構築

自分の勝手な主題としてdockerfileやdockerhubからのpullで(簡単に誰でも)環境を構築できるように練習したいのが主題。
今回スクレイピングで楽天トラベルのサイトからレビューを持ってくるため、形態素解析にMeCabやneologdなどを個々人でセットアップするのだるいよね〜〜
という背景があり、Dockerなら共通でいけるやん〜みたいな感じでdockerの勉強がてら作りました。

gitコード上のDockerフォルダにDockerfileは作っていますが、普通に

docker pull dockyupy/nlp-mecab-python:latest

でいけます。
dockerのimageは重くなりがち(たしか今回使うイメージも7GB。。。)なので、
ローカルのストレージが足りないときは AWS上で実行するのがおすすめなんですが、
AWS上でdockerを使えるようにしないといけなかったりもあるので、任せますw

とはいえ、常にimageを残す必要もないので、実行は

docker run -p 8888:8888 -v (作業ファイルのある場所):/(お好きな作業環境名) --rm dockyupy/nlp-mecab-python:latest

みたいに--rmコマンドで終了したら削除するようにして、その後rmiコマンドですぐに消すとかが個人的推奨。

一応、Dockerfileの中身を入れときます。

FROM ubuntu:latest
RUN apt update && apt install -y \
    curl \
    file \
    git \
    libmecab-dev \
    make \
    mecab \
    mecab-ipadic-utf8 \
    sudo \
    wget \
    vim \
    xz-utils

# install anaconda3 you can change the version if you want to 
WORKDIR /opt
RUN wget https://repo.anaconda.com/archive/Anaconda3-2021.05-Linux-x86_64.sh && \
    sh Anaconda3-2021.05-Linux-x86_64.sh -b -p /opt/anaconda3 && \
    rm -f Anaconda3-2021.05-Linux-x86_64.sh

ENV PATH=/opt/anaconda3/bin:$PATH
RUN pip install --upgrade pip && \
    pip install mecab-python3 && \
    pip install mojimoji && \
    pip install unidic-lite
WORKDIR /
# get the ipadic-neologd
RUN git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git && \
    echo yes | mecab-ipadic-neologd/bin/install-mecab-ipadic-neologd -n -a
CMD ["jupyter", "lab", "--ip=0.0.0.0", "--allow-root", "--LabApp.token=''"]

anaconda のインストールから、MeCab・neologdのセットアップまでしていて、すぐに
import MeCab
を使えるようにしました。

スクレイピング

特に複雑なスクレイピングコードでもなく、オーソドックスにrequests, bs4で取得したい項目を拾ってきています。
スクレイピングについてガツガツ解説するのは、のちのち。

def make_reputation_csv(AREA_URL='https://travel.rakuten.co.jp/yado/aichi/A.html'):
    try:
        soup_area = modules.get_soup(AREA_URL)
        hotels = soup_area.select('section > div.htlHead> div.info > span')


        for hotel in hotels:

            hotel_ID = hotel['id'][-6:]
            hotel_review_url = f'https://travel.rakuten.co.jp/HOTEL/{hotel_ID}/review.html'

            # get data souce
            soup_hotel = modules.get_soup(hotel_review_url)

            # hotel name
            hotel_name = soup_hotel.select("#RthNameArea > h2")[0].text

            _reputations, _comments, _purposes, _companions, _dates = modules.make_object_lists(soup_hotel)

            # make df and to csv
            data = pd.DataFrame(
                        {'HotelName': hotel_name,
                         'Date': _dates, 
                         'Reputations': _reputations, 
                         'Comments': _comments, 
                         'Purposes': _purposes, 
                         'companions': _companions}
                    ).to_csv(f'./sample_csv/{hotel_ID}_reputation.csv', encoding='utf-8', index=False)

            time.sleep(2)
    except:
        print('実行は終了されました')

このコードのsleep時間が短いかもなので、多用したらすぐにサイトから弾き出されるので、一回だけって感じです。

多クラス分類

今回は精度向上の目標が本題ではないので、SVM(一応RandomizeSearchCVは使った), MultinomialNBでどのくらいの精度が出たのかくらいをみました。
全て RakutenTravel_sc_predict.ipynbで作業しているので、いろいろ追いやすいかなと思いますが、一応ここでも少し書いときます。

データ読み込み〜前処理

一応今回欠損値はcsvにはないので、補完作業はいらないです。
また、今回は分析に使わなかったですが、object-> datetimeに変換と
本題の形態素への変換だけ行いました。

# データ読み込みを一括で
csv_lists = glob('./sample_csv/*')
data_dict = {}
preffixes = 'data'
for i, csv in enumerate(csv_lists):
    data_dict[preffixes+str(i+1)] = pd.read_csv(csv)


data = pd.concat([data for data in data_dict.values()]).reset_index(drop=True)

多少癖のある読み込み方かもですが、いちいちread_csvするのも今回はめんどくさいので、
dictに格納し、一気に読み込ませました。
これによりいちいちcsvの名前を書き込む手間も、命名も簡単に行えるので、最近勉強したなかでかなり有意義なコードでしたので、ここでも共有しておきます

preffixを先に定義することで{data1: (Dataframe), data2: (DataFrame), ...}のように簡単に連番を振ることができます。

データ読み込めば、次は日付変換・形態素解析をしておきます。

data['Date'] = pd.to_datetime(data['Date'], format='%Y年%m月')
data['Comments'] = [word.strip() for word in data['Comments']] # 不要な\n, \tを削除
data['Comment_words'] = data['Comments'].apply(lambda x: text_to_word(x))

このtext_to_wordは文書を形態素解析して、stopwordの除去、全ての形態素を原型に置換する関数です。
割と個人的に便利と思っている関数

nlp_modules.py
tagger = MeCab.Tagger("-d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd")
tagger.parse("")

def text_to_word(text, stopword_pass="./stopwords/Japanese.txt"):
    """
    Tokenize texts with MeCab neologd

    Parameters
    ----------
    text: str
        text to tokenize
    stopword_pass: str
        path of stopwords

    Returns
    -------
    basic_words_list: str
        tokenized and basic text

    """

    #stopwordのリスト作成
    stopword_list =[]
    with open(stopword_pass, "r") as file:
        lines = file.readlines()

        stopword_list = [stopword for stopword in lines if stopword.strip()]

    #mojimojiで全角数字、英字を半角に統一。大文字もすべて小文字に統一する
    text = mojimoji.zen_to_han(text, kana=False).lower()
    #解析
    parse_text = tagger.parse(text)

    #ここから解析したものの原型などを取り出して抽出していく
    basic_words_list = []

    #各単語の解析結果ごとにsplitしていく
    split_parse_text = parse_text.split("\n")

    for parse_word in split_parse_text:
        #\tで区切り、表層と品詞の情報に分ける
        split_parse_word = parse_word.split("\t")
        surface_word = split_parse_word[0]

        #最終行はEOSのため、終了させる
        if surface_word =="EOS":
            break
        else:
            #品詞が動詞、形容詞の場合、原形を格納
            morph_info = split_parse_word[1]
            morphs = morph_info.split(",")
            #品詞情報
            morph = morphs[0]
            #原型
            #原型は品詞情報の後ろから3番目
            basic = morphs[-3]

            if morph == "記号":
                continue
            elif morph in("動詞", "形容詞") and basic not in stopword_list:
                basic_words_list.append(basic)

            elif morph =='名詞' and basic not in stopword_list:
                basic_words_list.append(basic)

    #最終的にまとめたものを半角スペースでjoinし、リストで返す
    basic_words_list = " ".join(basic_words_list)
    return basic_words_list

なっっがいですが、割とシンプルなコードかなと思います。
逐次的な説明は端折りますが、ここでMeCabとか使えるのはDockerのおかげw

多クラス分類

前処理があらかた終わったので、分析へ
今回、Tfidfを使うので、sprase marixが出力として出てくるので、実際扱い方の練習にもなりました。

data_copy = data.copy()
X = data_copy[['Comment_words', 'Purposes', 'companions']]
X = pd.get_dummies(X, columns=['Purposes', 'companions'], drop_first=True)
y = data_copy['Reputations']

tfidf = TfidfVectorizer(min_df=4, max_df=.7)
x = tfidf.fit_transform(X['Comment_words'])
X_sparse = X.drop('Comment_words', axis=1).astype(pd.SparseDtype("int", np.nan))
X = hstack((x, X_sparse))

TfidVecがsparce matを返すため、get_dummiesで作られたデータもsprace matに変換する必要が(多分)あります
このへん、自分も怪しいのでもっといい方法があると思うので、調べときます。

あと、sprase matへの変換方法もastypeしかないのか?っていうのもあり、非常に微妙なコードですw

ま、気をとりなおして分析へ

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
nb = MultinomialNB()
nb.fit(X_train, y_train)

y_pred = nb.predict(X_test)
print(accuracy_score(y_true=y_test, y_pred=y_pred))
print(f1_score(y_true=y_test, y_pred=y_pred, average='macro'))

0.5714285714285714
0.1865531914893617

まずはTfidfと相性がいいMultinomialNBから。
KaggleのNLPのデータセットでこれ使っている人見て勉強しました。
多クラスかつ離散のときつかえます

とはいえ、rawデータはやはり精度も高くないすね〜〜
Tfidfのハイパーパラメータも少しいじりましたけど、そんなでした。

ラスト。

SVMでやってみます。
ま、本命ですかね?w

param_dst = {'C': np.arange(0.01, 10, 0.3), 'kernel': ['rbf', 'poly'], 'gamma': [0.01, 0.1]}

RS = RandomizedSearchCV(estimator=SVC(), param_distributions=param_dst, n_iter=50, cv=10, random_state=123)
RS.fit(X_train, y_train)
y_pred2 = RS.predict(X_test)

print('f1', f1_score(y_true=y_test, y_pred=y_pred, average='macro'))
print('acc', accuracy_score(y_true=y_test, y_pred=y_pred2))

f1 0.17315837339223075
acc 0.6134453781512605

お!ちょいあがりましたw
けど、頑張っても61%なので、むむむ〜〜って感じで、実践的なモデル構築とまではいってないですな。

まとめ

今回はDockerによる環境構築をしてみたい!というところから始まり、データをスクレイピングで集めて多クラス分類までしてみました。
実際にはgensimのword2vecとかで文章要約とかはコード作ろうかな〜と思っているんで、その時はまた書きます。
あと、noteにいろんなコラム書いてたりするので、それをQiitaに移行しようかしら?とか思ってたりもします。
もし何かしらの役に立てれば幸いです!