サウナイキタイの口コミ（サ活）をコーパス分類する

#1.はじめに
筆者は、Aidemy　PremiumPlanデータ分析コースを受講し、本記事は最終課題の成果物をテーマとしたブログ作成となっています。今回は自分の興味があるもので作成しました。次回以降はより難易度の高いものに挑戦していきたいと思います

#2.簡単な概要
筆者はサウナが大好きであり、週に2~3日ほどサウナに行くミドルサウナーです。(浴室なしのアパートで家賃を抑えて、毎日行けないか検討しています)
良いサウナに出会うには、サウナ検索サイト「サウナイキタイ」は欠かせません。
サウナイキタイで人気の施設は日々サ活(口コミ)更新されて賑わっていて、
サ活の内容で施設の特徴がなんとなく分かります。(例えば、昭和ストロングといえば100℃を超える高温でカラッカラのドライサウナということが読み取れます。)
そこで今回は、施設毎のサ活情報で機械学習をしたモデルを作成し、新しいサ活に対してどの程度施設を分類出来るか検証してみました。


#3. 使用環境
**GoogleColaboratory**
こちらは環境構築は不要でブラウザ上で実行出来ます。
まだ仮想環境の構築が出来ていないのでさくっと試すのもとても便利です。


#4.実装
まずは、5つのサウナ施設のサ活をrequests、BeautifulSoupによってクローリング、スクレイピングし、txt形式で保存しました。

他の記事でも言われていますが、クローリングやスクレイピングでデータにアクセスする時には、相手のサーバーに負荷をかけることは避ける必要があります。アクセス集中によりサービス妨害の攻撃みなされることがあります。
基本は以下のコードでリクエストの間隔を空けるようにしたほうがいいようです。


```
timeモジュールのインポート
import time

#引数には任意のステップで待機する時間を記述
time.sleep()
```



それでは実際のコードを見ていきましょう。

```Python:scraping.py
# 必要なライブラリのインポート
import requests
from bs4 import BeautifulSoup
import os

# はじめに、ベースとなる1ページ目のURLを定義する
base_url = "http://scraping.aidemy.net"

#saunas/店舗の固有番号/posts(サ活一覧ページ)
url_lists = ["https://sauna-ikitai.com/saunas/2303/posts",#各務原温泉 恵みの湯
            "https://sauna-ikitai.com/saunas/1551/posts",#馬橋湯 馬橋バスセンター
            "https://sauna-ikitai.com/saunas/1708/posts",#カプセルホテルレインボー本八幡店
            "https://sauna-ikitai.com/saunas/1523/posts",#湯乃泉 草加健康センター
            "https://sauna-ikitai.com/saunas/4044/posts"]#サウナと天然温泉 湯らっくす

# リスト内のHTMLを取得
for url_list in url_lists:
  # スクレイピング対象の URL にリクエストを送り HTML を取得する
  response =  requests.get(url_list)
  # BeautifulSoupによるHTMLのパース処理
  soup = BeautifulSoup(response.text, "lxml")
  # サウナ施設の固有番号を取得
  path = url_list.split("/")[-2]
  # サ活一覧の最後のページリンクを取得
  last_page  = soup.find_all("li",class_="c-pagenation_link")[-2]
  # 最後のページ番号を取得
  last_page = int(last_page.a.get("href").split("=")[-1])

  # ディレクトが存在するか確認し、なければディレクトリを作成する
  if not os.path.isdir(path):
        os.makedirs(path)

  # サ活一覧の1~7ページ分を取得する
  for i in range(1,min(last_page,8)):#1~7ページまで
    search_url = "https://sauna-ikitai.com/saunas/{}/posts?page={}".format(path,i)
    # スクレイピング対象の URL にリクエストを送り HTML を取得する
    response_page =  requests.get(search_url)
    # BeautifulSoupによるHTMLのパース処理
    soup_page = BeautifulSoup(response_page.text, "lxml")
    # 各ページの上位サ活順にラベル付け
    index=0
    # サ活のテキスト情報を全て取得
    boxes = soup_page.find_all("p",class_="p-postCard_text")
    for box in boxes:
      s = box.get_text(strip=True) #strip=Trueでテキスト内の改行や空白文字を削除
      #ラベル番号の更新
      index+=1
      #サ活に何も書いてない、チェックイン以外を保存
      if len(s)>0 and s!="チェックイン":
        #ファイル名を、サウナ施設の固有番号/ページ数_ラベル番号で保存
        with open("{}/{}_{}.txt".format(path,i,index), mode='w') as f:
          f.write(s)
```

ディレクトリには以下のようにtextファイルが保存されています。

![scraping path.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1034695/a6101428-c9a4-ca6d-2bce-84510e8d73bf.png)





クローリングとスクレイピングについては以下の記事が参考になりました。
[Python,Requestsの使い方](https://note.nkmk.me/python-requests-usage/)
[10分で理解するBeautifulSoup](https://www.yoheim.net/blog.php?q=20170802)



次に、空のリストを作成し、取得したテキストを改行文字で分割して、['文書~~1', '文書~~2', '文書~~3', ... ]のようにリストの中に加えていきます。


```Python:predict.py
#必要なライブラリのインポート
import glob

#カテゴリ辞書を定義
def load_sauna_text():
    category = {
        'yunoizumi': 1,
        'meguminoyu': 2,
        'rainbow-motoyawata': 3,
        'yulax': 4,
        'mabashi-yu': 5,
    }

    # 空の配列を準備
    docs = []
    labels = []
    # 全てのカテゴリのディレクトリについて実行
    for c_name, c_id in category.items():
        files = glob.glob(
            "/content/drive/MyDrive/text/{c_name}/*.txt".format(c_name=c_name))
        # 空の変数を準備
        text = ''
        for file in files:
            with open(file, 'r', errors='ignore') as f:
                #文字列+splitlines() :デフォルトで改行文字があった場合は分割してリストとして返
                lines = f.read().splitlines()

                text="".join(lines)
            #textをdocsに追加  
            docs.append(text)
            #C_idをlabelに追加
            labels.append(c_id)

    return docs, labels


docs, labels = load_sauna_text()
```



次にリストの中に入れた日本語の文章を形態素に分解します。
形態素とは、意味を持つ表現要素での最小単位です。
例えば、「隣の客はよく柿食う客だ」という文章を形態素に分解すると、
「隣/の/客/は/よく/柿/食う/客/だ」に分解が出来ます。
形態素に分解する事で不要なワードを除き、重要なワードを抽出することが出来ます。
しかし、形態素まで分解することはしてもまだモデルに学習させることは出来ません。
そこでベクトル化(数字のリスト)にする必要があります。

###文章のベクトル化
文章中の単語の出現頻度に表現する**Bag of Word(BOW)**を使います。
**BOW**にはカウント表現という文章中にある単語の出現回数を要素として使用したベクトルに変換する方法があります。
しかし、カウント表現の問題点としては、「です」や「ます」のような、カテゴリ分類において重要ではない単語の値が大きくなってしまうことがあります。
そこで、今回は**tf-idf表現**という手法で計算された、文章中の各単語の重み情報を扱う方法を使います。
**tf-idf**は各文書毎での単語の出現頻度である  **tf(Term frequency)** と、特定の単語が含まれる文書の頻度の逆数**idf (Inverse Document Frequency)** の 積で表されます。
**tf-idf**を使うと、多くの文書に出現する語（一般的な語）の重要度を下げ(tf-idf値が、特定の文書にしか出現しない単語の重要度を上げる役割を果たします。これにより、「です」や「ます」などの値が小さくなり、正しく重要度を設定することができます。
例として文書1：the man went out for a walkと文書2：the children sat around the fireの2文書に含まれる単語のtf-idfは以下のように計算されます。
・文書1
　・the     ：(1/7)×(2/2) = 1/7
　・man     ：(1/7)×(2/1) = 2/7
・文書2     
　・children：(1/6)×(2/1) = 1/3
　重要度は、chidren > man > the の順に高いことが分かります。

参考記事：[【Python】自然言語処理で使われるTF-IDFと単純ベイズ分類器(Naive Bayes)について使いながら解説する](https://qiita.com/tomone_hata/items/67e7f9415dbf5c8ff8ba)


以下が形態素に分解し、tf-idf による重み付けを行うコードになります。
ますは、前処理の効果を確認する為、まずは前処理をせずに(文章をそのまま使用する)コードを作成します。

```Python:predict.py
# indices は0からドキュメントの数までの整数をランダムに並べ替えた配列
random.seed()
indices = list(range(len(docs)))
# 9割をトレーニングデータとする
separate_num = int(len(docs) * 0.9)

random.shuffle(indices)

train_data = [docs[i] for i in indices[0:separate_num]]
train_labels = [labels[i] for i in indices[0:separate_num]]
test_data = [docs[i] for i in indices[separate_num:]]
test_labels = [labels[i] for i in indices[separate_num:]]

# テキストを分割する関数
t = Tokenizer()

def tokenize1(text):
    tokens = t.tokenize(",".join(text))
    noun = []
    for token in tokens:
        noun.append(token.surface)
    return noun


# Tf-idfを用いてtrain_dataをベクトル化
vectorizer = TfidfVectorizer(tokenizer=tokenize1)
train_matrix = vectorizer.fit_transform(train_data)
```


###ナイーブベイズとランダムフォレストによる学習
今回は**ナイーブベイズ**と**ランダムフォレスト**で学習を行います。
**ナイーブベイズ**は、ベイズの定理を用いてある文章をカテゴリ分けする際に、テキスト中の単語の出現率を調べます。
その際に文章がどのカテゴリに分類するのが相応しいか調べます。
参考記事：[［AI・機械学習の数学］機械学習でよく使われる「ベイズの定理」を理解する](https://www.atmarkit.co.jp/ait/articles/2101/07/news018.html)[【Python】自然言語処理で使われるTF-IDFと単純ベイズ分類器(Naive Bayes)について使いながら解説する](https://qiita.com/tomone_hata/items/67e7f9415dbf5c8ff8ba)

**ランダムフォレスト**は、少しずつ異なる決定木をたくさん用意し、アンサンブル学習のバギングによって過学習をしてしまう度合いを減らすことが出来ます。
決定木とは、クラス分類、回帰タスクに広く用いられて、Yes/Noで答えられる質問で構成された階層的な木構造を学習します。決定木の深さに制約を与えないと、決定木はいくらでも深く複雑になり、過学習をしやすく汎化性能が低い傾向にあります。
決定木をたくさん用意したランダムフォレストの回帰であれば各決定木の予測値の平均、分類であれば多数決で予測を行います。
参考記事：[【機械学習】ランダムフォレストを理解する](https://qiita.com/Hawaii/items/5831e667723b66b46fba)


```Python:predict.py
# ナイーブベイズを用いて分類を行う
clf = MultinomialNB()
clf.fit(train_matrix, train_labels)

# ランダムフォレストを用いて分類を行う
clf2 = RandomForestClassifier(n_estimators=100)
clf2.fit(train_matrix, train_labels)

# テストデータを変換
test_matrix = vectorizer.transform(test_data)
```

次に前処理として文章中で重要そうな品詞データのみを抽出して予測精度を求めてみます。
今回は、「名詞」、「動詞」、「形容詞」、「形容動詞」の形態素を抽出しました。

```Python:predict.py
# 単語の抽出
def tokenize2(text):
    tokens = t.tokenize(text)
    noun = []
    for token in tokens:
        # 「名詞」「動詞」「形容詞」「形容動詞」を取り出す
        partOfSpeech = token.part_of_speech.split(',')[0]

        if partOfSpeech == '名詞':
            noun.append(token.surface)
        if partOfSpeech == '動詞':
            noun.append(token.surface)
        if partOfSpeech == '形容詞':
            noun.append(token.surface)
        if partOfSpeech == '形容動詞':
            noun.append(token.surface)
    return noun

# 単語の抽出して学習
t = Tokenizer()

vectorizer = TfidfVectorizer(tokenizer=tokenize2)
train_matrix = vectorizer.fit_transform(train_data)
test_matrix = vectorizer.transform(test_data)
clf.fit(train_matrix, train_labels)
clf2.fit(train_matrix, train_labels)

# 結果を表示
# 分類結果を表示
print("前処理を行っていないデータ")
print("ナイーブベイズによる学習結果")
print(clf.score(train_matrix, train_labels))
print("ナイーブベイズによる評価用データの予測精度")
print(clf.score(test_matrix, test_labels))
print("ランダムフォレストによる学習結果")
print(clf2.score(train_matrix, train_labels))
print("ランダムフォレストによる評価用データの予測精度")
print(clf2.score(test_matrix, test_labels))

print("前処理を行ったデータ")
print("ナイーブベイズによる学習結果")
print(clf.score(train_matrix, train_labels))
print("ナイーブベイズによる評価用データの予測精度")
print(clf.score(test_matrix, test_labels))
print("ランダムフォレストによる学習結果")
print(clf2.score(train_matrix, train_labels))
print("ランダムフォレストによる評価用データの予測精度")
print(clf2.score(test_matrix, test_labels))
```

>前処理を行っていないデータ
ナイーブベイズによる学習結果
0.4166666666666667
ナイーブベイズによる評価用データの予測精度
0.34615384615384615
ランダムフォレストによる学習結果
1.0
ランダムフォレストによる評価用データの予測精度
0.75

>前処理を行ったデータ
ナイーブベイズによる学習結果
0.9572649572649573
ナイーブベイズによる評価用データの予測精度
0.8269230769230769
ランダムフォレストによる学習結果
0.9978632478632479
ランダムフォレストによる評価用データの予測精度
0.7884615384615384

前処理により不要な単語の影響を少なくすることで予測精度を上げることが出来ました。
パラメーター調整していない状態では、ナイーブベイズでの予測精度は、「83％」、ランダムフォレストでの予測精度は、「79%」という結果になりました。

#5. 最後に
最後まで読んでいただきありがとうございます。
株価の予測などで特徴量をダミー変数化して...というようなデータ分析も良かったかもしれないのですが、まずは自分のモチベーションを保つ為、
興味のあるテーマで慣れていこうと思います。
今回は急いで仕上げたため、質の良いものとはいえないと思いますが、
日々キャッチアップした内容からブラッシュアップしていきたいと思います。
次回は「自分の好きなサウナ施設のサ活情報をベクトル化により類似度を算出してどのサウナ施設が最も近いか」ということをやっていきたいと思います。
今回は時間の関係でwordcloudによる可視化が出来なかったので合わせて作成したいと思います。

今回作成したコードはgithubの使い方に慣れる目的も兼ねてアップしておきます。
https://github.com/yushiA919/sauna-classData