RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

「Keras」とパラメータ最適化フレームワーク「Optuna」を使った2値分類モデルの作成

はじめに

本記事ではPythonライブラリ「Keras」を用いたレビューデータの2値分類用のニューラルネットワークモデルの作成についてまとめます。Kerasについてはインターネット上で多くの情報を手に入れられますが、本記事ではKerasによるモデルの作成に加えて、パラメータ最適化フレームワーク「Optuna」を用いた一部パラメータの自動最適化の試みについても紹介します。

Kerasとは

Kerasはオープンソースニューラルネットワーク/深層学習ライブラリです。ニューラルネットワークモデルを作成するためのライブラリにはKeras以外にも、TensorFlowやPytorchなどいくつか種類がありますが、Kerasは簡潔な記述でニューラルネットワークを作成できるといわれています。実際にKerasでは十数行でニューラルネットワークモデル周りのスクリプトを記述できます。

Keras公式サイト(日本語)

keras.io

Optunaとは

Outunaはハイパーパラメータ自動最適化フレームワークです。専門用語を使わずに簡単に説明すると、Optunaは「人が自ら調整する必要があった機械学習モデル等のパラメータの最適値を自動的に見つけてくれるツール」です。ニューラルネットワークモデルでは隠れ層のユニット数など一部のパラメータの最適化を人が行うことになりますが、Optunaを使うとそれらのパラメータの最適化を自動化できます。

www.preferred.jp

 

Kerasによるニューラルネットワークモデルの作成の流れ

今回は以下の流れでニューラルネットワークモデルを作成します。

    

Pythonのバージョンと利用ライブラリ

利用したPythonのバージョンは「Python 3.9.9」です。

今回のニューラルネットワークモデル作成に際し、以下の外部ライブラリを利用しています。 KerasはTensorflowのtensorflow.kerasモジュールを通して利用します。

データの入手

今回は英語のレビュー投稿に対し、内容が肯定的か否定的かを予測するニューラルネットワークモデルの作成を試みます。そのために必要なデータをKaggleから入手することにします。

Kaggleについて詳しくは公式サイトや紹介記事を参照して下さい。

www.kaggle.com

ai-kenkyujo.com

今回は、こちらのTrip Advisor Hotel Reviewsという英語のホテルのレビュー投稿のデータを使います。

www.kaggle.com

このデータは行毎にレビュー本文とレーティング(肯定的か否定的かの指標になります)がセットになっている形式のため、モデルの訓練用として使いやすいと思います。

    

*Alam, M. H., Ryu, W.-J., Lee, S., 2016. Joint multi-grain topic sentiment: modeling semantic aspects for online reviews. Information Sciences 339, 206–223.

※ レビュー本文を見てみると文法的に?な英文が結構ありますが、今回はそのまま使うことにします。(公開用に匿名化処理をした影響なのかもしれません。)

実行ディレクトリの整備

予め以下のような構造で実行ディレクトリを整備しておきます。実行用のpyファイル、ダウンロードしたレビューのcsvファイル、Tensorboardに表示するデータの保存先となるフォルダを配置します。

   

データの前処理

レビューデータはそのまま使わず、文字列の整形やデータの分割、文字列のベクトル化といった前処理を行います。まずはそのための関数をいくつか定義します。

文字列の整形

レビュー本文は英語のため、それほど複雑な処理は必要ないと思います。今回は、Pythonの前処理ライブラリのclean-textと組み込み関数のみで前処理を実施します。

Clean-text pypi.org

※ インストール時にはハイフンありのものを指定してください。「cleantext」という別のライブラリもあるので注意してください。

  pip install clean-text

※ Pythonスクリプトを掲載する際には、なるべく説明のコメントを入れ、関数にはGoogleスタイルのdocstringライクな説明をつけるようにします。

def normalize_doc(text, no_digits=False, no_numbers=False, to_ascii=False):
    """訓練データ正規化

    訓練用データ(テキスト)を正規化します。

    Args:
        text (str): 正規化したいテキスト
        no_digits (bool, optional): 桁数を維持したまま全ての数字を0に変換するかどうか(123は000に、5678は0000に変換されます)
        no_numbers (bool, optional): 桁数を残さず数字を全て指定した文字列(今回は0に設定しています)に変換するかどうか(123は0に5678も0に変換されます)
        to_ascii (bool, optional): 非アスキー文字をアスキー文字に変換するかどうか (日本語は非アスキー文字なため、日本語を含むテキストにはFalseを設定)

    Returns:
        str: 正規化済テキスト
    """

    # テキストの最初と最後の余分なスペースを除去します。
    text = text.strip()

    # 正規化用ライブラリを使ってデータを正規化します。ここではunicode errorの除去とアルファベットの小文字化のみを行っています。
    text = cleantext.clean(text, no_digits=no_digits, to_ascii=to_ascii, no_numbers=no_numbers, replace_with_number="0")

    return text

データの読み込みと分割

CSVファイルを読み込み、レーティングのつけ直しとデータ数の学習用と評価用への分割を行います。

レーティングのつけ直しでは、今回の予測を「レビューがポジティブかネガティブか」の2値予測(2値分類)とするため、レーティング1と2を「0」(ネガティブ)、レーティング4と5は「1」(ポジティブ)につけ直します。どちらとも取れそうなレーティング3は除外することにします。

また、データを学習用とモデルの評価用に分割する処理も行います。このとき、ポジティブレビューとネガティブレビューそれぞれで学習用と評価用に分割し、その後に学習用のポジティブレビューとネガティブレビュー同士、評価用のポジティブレビューとネガティブレビュー同士を結合します。

(全体の8割を学習用とした場合、すべてのネガティブレビューが学習用となり、評価用にはポジティブレビューしか含まれなくなることを防ぐためです。2番目の図で示す処理を行います。)

    

def load_data(path):
    """訓練データ、正解ラベルの加工と訓練用。評価用への分割

    CSVファイルから訓練データと正解ラベルを取り出し、正解ラベルの2値への変換と
    データの学習用と評価用への分割を行います。
    訓練用データのデータフレームと評価用データのデータフレームを返します。

    Args:
        path (str): レビュー本文とレーティングのCSVファイルのパス

    :Returns:
        pandas.DataFrame: 学習データのデータフレーム
        pandas.DataFrame: 評価用ラベルのデータフレーム
    """

    # レビュー本文とレーティングのCSVファイルをデータフレーム形式で読み込みます。
    review_df = pd.read_csv(path, header=0)

    # データフレームの列を参照するときに便利なので列ラベルを取得しておきます。
    columns = review_df.columns

    #  レビューを「ポジティブ」、「ネガティブ」の2値で予測するため、レーティング1-2は0(ネガティブ)に、4-5は1(ポジティブ)に変換します。
    #  今回中間の3は無視します。(3はポジティブともネガティブともとれる場合がありそうなため)
    mapping = {1: 0, 2: 0, 3: 3, 4: 1, 5: 1}
    review_df[columns[1]] = review_df[columns[1]].map(mapping)

    # ネガティブレビューのみでフィルターをかけます。
    negative_review_df = review_df[review_df[columns[1]] == 0]

    # ポジティブレビューのみでフィルターをかけます。
    positive_review_df = review_df[review_df[columns[1]] == 1]

    # ネガティブレビュー、ポジティブレビューそれぞれを学習用と評価用に分割します。今回は学習用8割、評価用2割に分割します。
    # 分割にはsklearnのtrain_test_splitを利用します。
    # レビュー本文の前処理を同時に行うためにいったんデータをリスト形式に変えています。
    xn = [normalize_doc(review_body) for review_body in negative_review_df[columns[0]]]
    yn = list(negative_review_df[columns[1]])
    xp = [normalize_doc(review_body) for review_body in positive_review_df[columns[0]]]
    yp = list(positive_review_df[columns[1]])

    xn_train, xn_test, yn_train, yn_test = train_test_split(
        xn, yn,
        test_size=0.2,
        random_state=42,
    )

    xp_train, xp_test, yp_train, yp_test = train_test_split(
        xp, yp,
        test_size=0.2,
        random_state=42,
    )

    # ネガティブレビューとポジティブレビューデータを結合し、学習用データのデータフレームと評価用データのデータフレームを作成します。
    x_train = xn_train + xp_train
    x_test = xn_test + xp_test
    y_train = yn_train + yp_train
    y_test = yn_test + yp_test

    # 学習用データのデータフレーム
    train = list()
    for x, y in zip(x_train, y_train):
        train.append([x, y])
    train_df = pd.DataFrame(train, columns=["Review", "Rating"])

    # 評価用データのデータフレーム
    test = list()
    for x, y in zip(x_test, y_test):
        test.append([x, y])
    test_df = pd.DataFrame(test, columns=["Review", "Rating"])

    # 関数の戻り値としてデータフレームをセットする際に「.sample(frac =1).reset_index(drop=True)」でデータをシャッフルしインデックスをふり直します。
    return train_df.sample(frac=1).reset_index(drop=True), test_df.sample(frac=1).reset_index(drop=True)

文字列のベクトル化

モデルの訓練の際、テキストデータをそのまま入力とすることができません。 ベクトル化というテキストを数値列に変換する作業が必要になります。そこで今回は「TF-IDF」というテキスト内の単語の重要性を反映した数値列によるベクトル化を行ってみます。

また、今回は「ストップワード」という頻度単語のフィルタを使わない代わりに、SklearnのTfidfVectorizerのパラメータの「max_df」を設定し、重要ではない単語の除去を試みます。

Tf-idfについて

atmarkit.itmedia.co.jp

Sklearnの公式ドキュメント

scikit-learn.org

def get_vectorizer(corpus_x_train, corpus_x_test):
    """テキストのTF-IDFによるベクトル化

    レビュー本文データをtfidfによってベクトル化します。ベクトライザー(ベクトルへの変換器)はデータのうち
    モデルの訓練に使うデータのみで訓練します。評価用データは学習データで訓練したベクトライザーを使いベクトル化します。

    Args:
        corpus_x_train (iterator): データのうち訓練用に分割した部分
        corpus_x_test (iterator): データのうち評価用に分割した部分

    Returns:
        sklearn.feature_extraction.text.TfidfVectorizer: 訓練後のTF-IDFベクトライザー
        ndarray: ベクトル化の対象となった語彙のリスト (ndarray of str objects)
        ndarray: ベクトル化した訓練用データ
        ndarray: ベクトル化した評価用データ

    ベクトライザー、ベクトル化の対象となった語彙のリスト、ベクトル化した訓練用データ、ベクトル化した評価用データ
    """

    # ベクトライザーを作成します。max_dfの値を設定すると指定した値の割合以上の文書に含まれる語彙はベクトル化の対象とならなくなります。
    # max_dfの値を0.3に設定していますが、データの実情に合わせて調整が必要です
    vectorizer = TfidfVectorizer(max_df=0.3)

    # 訓練用データを使ってベクトライザーを訓練し、同時に訓練用データのベクトル化も行います。
    vec_corpus_x_train = vectorizer.fit_transform(corpus_x_train)

    # 訓練したベクトライザーで評価用データのベクトル化を行います。
    vec_corpus_x_test = vectorizer.transform(corpus_x_test)

    return vectorizer, vectorizer.get_feature_names_out(), vec_corpus_x_train.toarray(), vec_corpus_x_test.toarray()

Kerasによるニューラルネットワークモデルの定義

データの前処理用関数の定義が終わったので、Kerasを使いニューラルネットワークモデルを定義していきます。 今回は次のようなニューラルネットワークモデルを定義していきます。

   

※ 中間層が2層あるニューラルネットワークモデルは深層学習モデルともみなせますが、本記事では「ニューラルネットワークモデル」と呼ぶことにします。

テキスト(今回はレビュー本文)のベクトルデータを入力とし、入力層→中間層1→中間層2→出力層と処理を行っていきます。 今回はKerasのFunctionalモデルを使いこのモデルを定義します。Kerasでは、モデルの定義に「Functional API」と「Sequentialモデル」が使えますが、複数入力、複数出力をするような複数なモデルを定義する際にはFuncitional APIを使います。(今回はベクトル1つを入力とするので複数入力ではありませんが、将来的な発展の可能性も考えて、Functional APIを選んでいます。)

KerasはTensorflowのtensorflow.kerasモジュールからインポートして使います。

keras.io

keras.io

Kerasでモデルを定義する際は、keras.layers.Inputやkeras.layers.Denseを使い各層のユニット数や活性化関数を定義していきます。 上の図では省略していますが、中間層1と中間層2の間、中間層2と出力層の間にDropout層(keras.layers.Dropout)を追加し過学習対策をします。(上の図では省略)

deepage.net

最後にkeras.Modelのcompileメソッドを使い、モデルの損失関数やオプティマイザを指定します。

def create_model(n_vocab, unit):
    """Kerasによるニューラルネットワークの定義

    KerasのFuncitional APIを使いニューラルネットワークモデルを定義します。

    Args:
        n_vocab (int): 入力として渡すデータの次元数です。入力層のユニット数となります。(ベクトライザーから取得する語彙のリスト内の要素数とします。)
        unit (int): 中間層のユニット数です。任意の数を設定できますが、今回はOptunaによる最適な値(整数)を自動で設定します。

    Returns:
        tensorflow.keras.Model: ニューラルネットワークモデル(オブジェクト)
    """

    # 入力層を追加します。ユニット数はvocabと同じです。
    input_ = keras.layers.Input(shape=(n_vocab,))

    # 中間層(全結合層)とドロップアウト層を追加します。ユニット数は引数unitで指定します。
    x = keras.layers.Dense(unit, activation="relu")(input_)
    x = keras.layers.Dropout(0.8)(x)

    # 2層目の中間層(全結合層)とドロップアウト層を追加します。ユニット数は引数unitで指定します。
    x = keras.layers.Dense(unit, activation="relu")(x)
    x = keras.layers.Dropout(0.5)(x)

    # 出力層を追加します。   ソフトマックス関数を使う2値分類のためユニット数は2です。
    output = keras.layers.Dense(2, activation="softmax")(x)

    # モデル(オブジェクト)を作成します。
    model = keras.Model(inputs=input, outputs=output)

    #  パラメータの更新や損失関数、評価関数に何を使うかを設定します。変更する必要はないかと思います。
    model.compile(
        optimizer="adam",
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"]
    )

    # モデルの概要を出力します。
    print(model.summary())  

    return model

モデル作成・訓練・評価実行用クラスの定義

Kerasによるニューラルネットワークモデルの定義ができたので、次に「モデル(のオブジェクト)を作成し、そのモデルの訓練を行うクラス」を作成します。 これまでに作成した前処理関数を使い、学習データを整え、ニューラルネットワークもですの作成、訓練から評価までを行うクラスとしてみます。

class NnModel:
    """ニューラルネットワーク作成・訓練・評価用クラス

     ニューラルネットワークモデルオブジェクトの作成、訓練、評価を行うためのクラスです。

    Attributes:
        vec_x_train (ndarray): ベクトル化済の訓練用データ
        x_test (pandas.Series): ベクトル化前の評価用データ
        vec_x_test (ndarray): ベクトル化済の評価用データ
        y_train (pandas.Series): モデルの訓練時に使う正解ラベル
        y_test (pandas.Series): モデルの評価時に使う正解ラベル
        model (tensorflow.keras.Model): model_fit実行時に作成されるニューラルネットワークオブジェクト

    """

    def __init__(self, path):

        # CSVファイルから訓練データと正解データを取得します。
        train_df, test_df = load_data(path)
        columns = train_df.columns
        x_train = train_df[columns[0]]
        self.x_test = test_df[columns[0]]
        self.y_train = train_df[columns[1]]
        self.y_test = test_df[columns[1]]

        # 訓練データをベクトル化します。
        self.vectorizer, self.vocab, self.vec_x_train, self.vec_x_test = get_vectorizer(x_train, self.x_test)

        # 後で訓練後のモデルを利用する際に必要になるので、ベクトライザーのオブジェクトをpickleファイルに保存しておきます。
        with open("my_vectorizer.pickle", "wb") as f:
            pickle.dump(self.vectorizer, f)


def model_fit(self, unit, logdir, validation_split=0.2, epochs=20, batch_size=32):
    """ニューラルネットワークモデル (tensorflow.keras.Model)の作成と用意した訓練データでの訓練
    
    ニューラルネットワークモデルオブジェクトを作成し、用意した訓練データで訓練します。
    Optunaとの連携のため、ニューラルネットワークオブジェクトはこのメソッドで作成します。

    Args:
        unit (int): 中間層のユニット数です。今回はoptunaによって最適な値を設定します。
        logdir (str): 訓練時の学習ログを保存するフォルダを指定します。(tensorboardを使う場合にこのフォルダ内のログを参照します。)
        validation_split (float): 訓練時(keras.Modelのfitメソッド実行時)の各エポックで検証用に使う訓練データの割合を指定します。
        epochs (int): 訓練データを繰り返して学習させる回数を指定します。
        batch_size (int): 何件の訓練データ毎にパラメータの更新を行うかを指定します。
        
    Returns:
        float: モデルの評価用データでの損失値
    """

    # ニューラルネットワークモデル(オブジェクト)を作成します。
    self.model = create_model(len(self.vocab), unit)

    # 学習の進行状況の可視化や精度が向上しなくなった際の学習の早期終了を行う関数をコールバックにまとめます。
    tensorboard = keras.callbacks.TensorBoard(log_dir=logdir)
    early_stopping = keras.callbacks.EarlyStopping(monitor='val_loss', patience=3)
    model_name = "my_neural_model.h5"
    modelCheckpoint = keras.callbacks.ModelCheckpoint(
        filepath=model_name,
        monitor='val_loss',
        verbose=1,
        save_best_only=True,
        save_weights_only=False,
        mode='min',
        period=1)
    callbacks = [tensorboard, early_stopping, modelCheckpoint]

    # モデルの訓練を実施します。
    self.model.fit(
        self.vec_x_train, self.y_train,
        validation_split=validation_split,
        epochs=epochs,
        batch_size=batch_size, 
        callbacks=callbacks  # 上で定義したコールバック関数のリストを指定します。
    )

    # my_neural_model.h5に保存されている(各エポックのうち最も精度の良かった)モデルを読み込んでテスト用データで精度を算出します。
    model = keras.models.load_model(model_name)
    pred = model.evaluate(self.vec_x_test, self.y_test)

    # ニューラルネットワークモデルの隠れ層の最適ユニット数の推定のため、今回はテストデータでの損失値を学習メソッドの戻り値としておきます。
    return pred[0]


def model_predict(self):
    """ニューラルネットワークモデルの性能評価
    
    train_test_splitで分割したデータのうち評価用のデータを使い、
    model_fitで訓練したモデルの性能を評価します。
    
    Returns:
        list: モデルの予測結果のリスト(予測ラベルのリスト)
    """

    # (keras.Modelの)predictメソッドに評価用のデータを渡します。
    y_pred = self.model.predict(self.vec_x_test)

    # 予測結果のうち、「1」(あり)、「0」(なし)のうち予測数値が高かった方をリストに追加します。
    y_pred = [p.argmax() for p in y_pred]

    # sklearnのスコア算出用の各関数に予測ラベルと正解ラベルを渡しモデルの性能を評価します。
    print("result",f"accuracy: {accuracy_score(self.y_test, y_pred)}",
          f"precision: {precision_score(self.y_test, y_pred)}", f"recall: {recall_score(self.y_test, y_pred)}",
          sep="\r\n")
    
    return y_pred

Optunaによる最適なパラメータの探索

Kerasを使ったニューラルネットワークモデルの作成から評価までを行うクラスが定義できたので、実行して結果を得ることもできるのですが、今回は中間層の最適なユニット数を「Optuna」を使って自動最適化してみます。

Optunaを使う際には、最適化したいパラメータとそのデータ型、ある値が最適かどうかを判断するための指標に何を使うかを指定します。今回は:

  • 最適化したいパラメータとそのデータ型: 中間層のユニット数 整数(int)型
  • 最適かどうかの指標: 指定したパラメータでのモデル訓練後の損失値

という条件でパラメータの自動最適化を実施します。

keras.io

keras.io

def objective(trial):
    """Optunaでパラメータ推定を行う際の処理内容
    
    Optunaでパラメータ推定を行うための関数です。
    目的のパラメータを変更した際の精度の指標となる数値が戻り値となるようにしておく必要があります。
    (値が改善すればより良いパラーメータ値とみなします。)
    
    Args:
        trial: OptunaのStudyオブジェクトが設定します。
        
    :Returns:
        float: Optunaがパラメータを設定した作成したモデルの損失値(精度指標)
    """

    # 今回はニューラルネットワークモデルの中間層のユニット数の自動推定を試みます。
    # 指定した数値(今回は2-30)の間で最も精度が高くなる数値を最適なユニット数とみなします。
    # 推定する数値は後で参照するために"unit"と指定しておきます。
    unit = trial.suggest_int("unit", 2, 30)
    loss = nn_model.model_fit(unit, r"logs", epochs=100)

    return loss

最適なパラーメータの探索とニューラルネットワークモデルの作成・訓練の実施

これまで定義してきた関数とクラス、Optunaを使い、最適なユニット数の探索と最適なユニット数を設定したモデルの作成を行います。 処理の流れは次のようになります。

  1. ニューラルネットワークモデル作成用オブジェクトの作成(学習/評価用データの前処理)
  2. OptunaのStudyオブジェクトを作成、最適なユニット数を探索
  3. 探索完了後にStudyオブジェクトからユニット数の数値を取得
  4. ニューラルネットワークモデル作成用オブジェクト(keras.Model)のモデル作成/訓練メソッド(fit)に最適なユニット数を渡してモデルを作成/訓練
  5. 評価用データでモデルを評価
# ニューラルネットワークモデル作成用オブジェクトを作成します。
target_data = r"tripadvisor_hotel_reviews.csv"
nn_model = NnModel(target_data)


def main():
    """実行用関数
    
    これまでに作成してきた関数とクラスを使い最適な中間層ユニット数の推定から
    モデルの定義、訓練、評価までを実行します。
    
    Returns:
        None
    """

    # oputunaで最適なユニット数を見つけます。最後にBest unitとして出力されます。
    # directionは損失関数の場合はminimizeを指定します。(正解率を精度指標とする場合にはmaxizeを指定します。)
    study = optuna.create_study(direction="minimize")

    # 最適なユニット数を見つけるために何度試行するかを指定し、自動推定を実行します。
    study.optimize(objective, n_trials=10)

    # 最適なユニット数はこの変数から best_params["unit"] とすると取得できます。ディレクトリ型の参照と同じです。
    best_params = study.best_params
    print(f"Best unit: {best_params}")

    # 最適な数値を中間層のユニット数に指定し、モデルの定義と訓練、評価を行います。
    nn_model = NnModel(target_data)
    nn_model.model_fit(best_params["unit"], r"logs", epochs=100)
    nn_model.model_predict()


if __name__ == "__main__":
    main()

今回作成したモデルの評価結果は以下のようになりました。

    Accuracy: 0.9555

    Precision: 0.9609

    Recall: 0.9861

かなり良い数値ですが、Tensorboardでモデル損失のグラフを確認してみると、2エポック以降減少が見られません。対策はしていましたが過学習をしてしまったようです。 (濃い青の線が学習データでの損失値、水色の線が評価用データでの損失値)

    

(Tensorboardでは学習データでモデルを繰り返し訓練する中で、どのように性能が変化していったかを確認することができます。)

(model_fitメソッドのtensorboard = keras.callbacks.TensorBoard(log_dir=logdir)の行でtensorboardの設定を行っています。)

www.tensorflow.org

deepage.net

作成したニューラルネットワークモデルの利用

最後に作成したモデルを使って、未知のデータでの予測を行うための関数を作成してみます。 モデル作成時に使ったベクトライザーと保存したニューラルネットワークモデルを読み込み、それらに予測を行いたいデータ(テキスト)を渡して予測結果の確率を出力します。 保存したモデルは、tensorflow.keras.models.load_modelで読み込みます。

def model_load_predict(x, model_h5, vec_pickle):
    """ニューラルネットワークモデルを利用した未知データに対する予測用関数
    
    作成・保存したニューラルネットワークモデルを利用して、未知データの予測を行います。
    
    Args:
        x (str):  テキストデータ(レビュー本文と同じ形式)
        model_h5 (str):  作成したニューラルネットワークモデルのh5ファイルのパス
        vec_pickle (str):  保存したベクトライザーのpickleファイルのパス
    
    Returns:
        int: 予測ラベルの整数値(入力したテキストをネガティブと予測した場合は0,、ポジティブと予測した場合は1)
    """

    # モデルとベクトライザーを読み込みます。
    model = keras.models.load_model(model_h5)
    with open(vec_pickle, 'rb') as f:
        vectorizer = pickle.load(f)

    # 入力データの正規化とベクトル化を行います。
    x = normalize_doc(x)
    vec_x = vectorizer.transform([x]).toarray()

    # 入力データに対して予測を行い、結果を出力します。
    pred = model.predict(vec_x)
    print("== Result ==")
    print("Positive") if pred[0][1] > pred[0][0] else print("Negative")
    print(f"Positive: {pred[0][1]}, Negative: {pred[0][0]}")

    return pred.argmax()


if __name__ == "__main__":
    x = input(">>>")

    model_h5 = "my_neural_model.h5"
    vec_pickle = "my_vectorizer.pickle"
    res = model_load_predict(x, model_h5, vec_pickle)

    print("予測ラベル: ", res)

関数を実行し「>>>」と表示されたら、ネガティブかポジティブかを予測したいテキストを入力します。テキスト入力後に「enter」を押すと予測結果が出力されます。(予測結果の前に警告が表示されることがありますが、今回は無視します。)

    

    

結果と考察

KerasとOptunaを使い、実際に予測を行うことができるニューラルネットワークモデルを作成することができました。 このモデルは評価結果の数値を見ると悪くないように見えるのですが、未知のレビューデータに対する予測性能が高いとは言えません。一目でポジティブとわかる内容のレビューに対して、99%ネガティブという予測をすることもありました。

今回の反省点としては、

  • データの質が良くなかった。

  → 今回は文法的に怪しい英文もそのまま使いました。これが良くなかったのかもしれません。

  → 非常に長いレビュー本文を持つレコードが含まれていましたが、この手のレコードは「いい点もあった。が、しかし…」という流れでポジティブな側面とネガティブな側面を持っている可能性があり、どちらかに分類するには容易ではないのかもしれません。

  • データが足りなかった。

  → 2万レコードでは足りなかったのかもしれません。

  • ベクトル化の方法が良くなかった。

  → TF-IDFよりも単語の意味を反映するとされる分散表現の方がよかったのかもしれません。

  • 別のハイパーパラメータもOptunaで最適化すべきだった。

  → ユニット数以外にも学習率やバッチサイズも最適な値をOptunaで設定するとよかったのかもしれません。

  → 今回のようなニューラルネットワークモデルよりもリカレントニューラルネットワークモデルの方がテキスト分類には適しているのかも知れません。

といったことが考えられます。このモデルを改善する際や新たなモデルを作成する際には、上記の反省点を踏まえる必要があると感じています。 特にデータに関しては、大量のデータが必要になるため、ある試行でうまくいかなかった際に、簡単に代替のデータを用意できるとは限りません。 自前でデータを用意する際には、最初から質の高いデータが揃うようにデータ収集作業を行う必要があると思います。

全体スクリプト

最後に、今回のニューラルネットワークモデル作成の試みに関わるスクリプト全体をまとめておきます。 テキストデータの分類であれば、少しの編集でいろいろなデータを扱えると思います。ぜひ、ご自身のデータで予測モデルを作成してみてください。

# coding: utf-8

import pandas as pd
import pickle
import cleantext
import optuna
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, precision_score, recall_score
from tensorflow import keras


def normalize_doc(text, no_digits=False, no_numbers=False, to_ascii=False):
    text = text.strip()
    text = cleantext.clean(text, no_digits=no_digits, to_ascii=to_ascii, no_numbers=no_numbers, replace_with_number="0")
    return text


def load_data(path):
    review_df = pd.read_csv(path, header=0)
    columns = review_df.columns

    mapping = {1: 0, 2: 0, 3: 3, 4: 1, 5: 1}
    review_df[columns[1]] = review_df[columns[1]].map(mapping)

    negative_review_df = review_df[review_df[columns[1]] == 0]
    positive_review_df = review_df[review_df[columns[1]] == 1]

    xn = [normalize_doc(review_body) for review_body in negative_review_df[columns[0]]]
    yn = list(negative_review_df[columns[1]])
    xp = [normalize_doc(review_body) for review_body in positive_review_df[columns[0]]]
    yp = list(positive_review_df[columns[1]])

    xn_train, xn_test, yn_train, yn_test = train_test_split(
                                            xn, yn,
                                            test_size=0.2,
                                            random_state=42,
                                            )

    xp_train, xp_test, yp_train, yp_test = train_test_split(
                                            xp, yp,
                                            test_size=0.2,
                                            random_state=42,
                                            )

    x_train = xn_train + xp_train
    x_test = xn_test + xp_test
    y_train = yn_train + yp_train
    y_test = yn_test + yp_test

    train = list()
    for x, y in zip(x_train, y_train):
        train.append([x, y])
    train_df = pd.DataFrame(train, columns=["Review", "Rating"])

    test = list()
    for x, y in zip(x_test, y_test):
        test.append([x, y])
    test_df = pd.DataFrame(test, columns=["Review", "Rating"])

    return train_df.sample(frac=1).reset_index(drop=True), test_df.sample(frac=1).reset_index(drop=True)


def get_vectorizer(corpus_x_train, corpus_x_test):
    vectorizer = TfidfVectorizer(max_df=0.3)
    vec_corpus_x_train = vectorizer.fit_transform(corpus_x_train)
    vec_corpus_x_test = vectorizer.transform(corpus_x_test)
    return vectorizer, vectorizer.get_feature_names_out(), vec_corpus_x_train.toarray(), vec_corpus_x_test.toarray()


def create_model(n_vocab, unit):
    input_ = keras.layers.Input(shape=(n_vocab,))
    x = keras.layers.Dense(unit, activation="relu")(input_)
    x = keras.layers.Dropout(0.8)(x)
    x = keras.layers.Dense(unit, activation="relu")(x)
    x = keras.layers.Dropout(0.5)(x)
    output = keras.layers.Dense(2, activation="softmax")(x)

    model = keras.Model(inputs=input, outputs=output)
    model.compile(
        optimizer="adam",
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"]
        )
    print(model.summary())

    return model


class NnModel:
    def __init__(self, path):
        train_df, test_df = load_data(path)
        columns = train_df.columns
        x_train = train_df[columns[0]]
        self.x_test = test_df[columns[0]]
        self.y_train = train_df[columns[1]]
        self.y_test = test_df[columns[1]]

        self.vectorizer, self.vocab, self.vec_x_train, self.vec_x_test = get_vectorizer(x_train, self.x_test)

        with open("my_vectorizer.pickle", "wb") as f:
            pickle.dump(self.vectorizer, f)


    def model_fit(self, unit, logdir, validation_split=0.2, epochs=20, batch_size=32):
        self.model = create_model(len(self.vocab), unit)
        tensorboard = keras.callbacks.TensorBoard(log_dir=logdir)
        early_stopping = keras.callbacks.EarlyStopping(monitor='val_loss', patience=3)
        model_name = "my_neural_model.h5"
        modelCheckpoint = keras.callbacks.ModelCheckpoint(
            filepath=model_name,
            monitor='val_loss',
            verbose=1,
            save_best_only=True,
            save_weights_only=False,
            mode='min',
            period=1)
        callbacks = [tensorboard, early_stopping, modelCheckpoint]

        self.model.fit(
                    self.vec_x_train, self.y_train,
                    validation_split=validation_split,
                    epochs=epochs,
                    batch_size=batch_size,
                    callbacks=callbacks
                    )

        model = keras.models.load_model(model_name)
        pred = model.evaluate(self.vec_x_test, self.y_test)

        return pred[0]


    def model_predict(self):
        y_pred = self.model.predict(self.vec_x_test)
        y_pred = [p.argmax() for p in y_pred]
        print("result", f"accuracy: {accuracy_score(self.y_test, y_pred)}",
              f"precision: {precision_score(self.y_test, y_pred)}", f"recall: {recall_score(self.y_test, y_pred)}",
              sep="\r\n")


def objective(trial):
    unit = trial.suggest_int("unit", 2,30)
    loss = nn_model.model_fit(unit, r"logs", epochs=100)
    return loss


target_data = r"tripadvisor_hotel_reviews.csv"
nn_model = NnModel(target_data)

def main():
    study = optuna.create_study(direction="minimize")
    study.optimize(objective, n_trials=10)

    best_params = study.best_params
    print(f"Best unit: {best_params}")

    nn_model = NnModel(target_data)
    nn_model.model_fit(best_params["unit"], r"logs", epochs=100)
    nn_model.model_predict()


def model_load_predict(x, model_h5, vec_pickle):
    model = keras.models.load_model(model_h5)
    with open(vec_pickle, 'rb') as f:
        vectorizer = pickle.load(f)

    x = normalize_doc(x)
    vec_x = vectorizer.transform([x]).toarray()

    pred = model.predict(vec_x)
    print("== Result ==")
    print("Positive") if pred[0][1] > pred[0][0] else print("Negative")
    print(f"Positive: {pred[0][1]}, Negative: {pred[0][0]}")

    return pred.argmax()


if __name__ == "__main__":
    main()

    x = input(">>>")
    model_h5 = "my_neural_model.h5"
    vec_pickle = "my_vectorizer.pickle"
    res = model_load_predict(x, model_h5, vec_pickle)
    print(res)

◆TECH PLAY
techplay.jp

◆connpass
rakus.connpass.com

Copyright © RAKUS Co., Ltd. All rights reserved.