Feedforce Developer Blog

フィードフォース開発者ブログ

Self-Attentionを用いてGoogle 無料リスティングの「拡張リスティングの不承認」に挑んだ話

こんにちは 株式会社フィードフォース2020年入社の機械学習エンジニア 八百俊哉@Feedforce (@feed_yao) | Twitterと申します。

最近はロードバイク にはまっており、ロードバイク購入後一ヶ月で一日100km走行に成功しました。

今回、Google無料リスティングで不承認アカウントが発生する要因を調査する分析を行いました。

Google 無料リスティングとは?

2020年10月にGoogleから公開されたGoogleショッピングタブに無料で商品掲載ができる「無料リスティング」のことです。

Google 検索にサイトがインデックス登録されても料金が発生しないのと同様に、EC事業者は無料で利用可能になりました。 Google 無料リスティングについての詳細は以下のサイトが参考になります。

lab.ecbooster.jp

なぜ今回分析が必要とされたのか?

無料リスティングでは自社製品を無料でGoogleに掲載できます。 しかしながら、課題として一部商品掲載が不承認となるケースが見受けられました。

不承認となってしまうと自社商品の掲載ができていない状況が発生しています。不承認となる理由としては、「Googleが定める基準に対して、登録している商品データの属性数が足りない、内容が仕様に沿っていない場合、商品データの品質が低いため不承認になり、Googleの検索結果に表示させることができません。」とされています。

これらを定量的に分析することで不承認となる理由を見つけ出す試みが始まりました。

そのため今回の分析の目的は、商品の属性情報(title,description)から承認・不承認の要因を見つけ出し、不承認の商品を承認へと改善するための施策を考案することです。

結果と考察

今回の目的である「商品の属性情報(title,description)から承認・不承認の要因を見つけ出し、不承認の商品を承認へと改善するための施策を考案する」は、達成できませんでした。

目的が達成できなかった理由として考えられる要因は、承認・不承認は商品のtitle,descriptionだけでは判断されていないということです。商品ごとのtitle,descriptionのみで承認・不承認が判断されているのではなく、商品データ全体またはアカウント全体のデータを総合的に見て、判断されている可能性が高いということがわかりました。

承認・不承認予測のAccuracyとしては5割〜6割ほどで、承認・不承認を予測するという点でも低い精度となってしまいました。

Self-Attentionを採用した理由

今回はSelf-Attentionという手法を用いてこの課題解決を試みました。

Self-Attentionとは、文章全体で重要とされるキーワードが予測結果と一緒に確認できるようになる手法です。

Self-Attentionの仕組みについては詳しく書かれている方が多くいますので、ここでは割愛します。

最初は、word2vecを用いて文章特徴量を作成し、承認・不承認を予測して終了という一連の流れを想定していました。

しかし、今回の目的は承認・不承認を予測したいわけではなく、どの単語が承認・不承認と関わっているのかを確認し、不承認となっているアカウントを承認にすることです。 もし仮にword2vecを用いた手法を採用すると予測結果の要因や理由が明確にならないので、不承認のアカウントを承認に改善する施策を考えることはできません。

そのため今回は、Self-Attentionを用いて分類モデルを構築することで、承認・不承認の要因が文章内のどこにあるのかを分析するために、この手法を選択しました。

実装手順

本来の目的は達成できませんでしたが、Self-Attentionでの分類モデルの実装はできましたので、実装方法を記載します。 今回はkerasを用いてSelf-Attention + LSTMで予測を行いました。 検証環境はGoogle Colaboratoryを想定しています。

使用データのフォーマット

今回使用できるデータとしては以下のようなデータになっています。

各アカウント・各商品ごとに商品IDが割り振られており、それぞれの商品にtitle,descriptionが割り振られています。

承認・不承認のラベルは、アカウントごとに付加されています。

f:id:newton800:20210224172435p:plain

必要ライブラリのインストール・インポート

!pip install text_vectorian
!pip install mojimoji
!apt install aptitude
!aptitude install mecab libmecab-dev mecab-ipadic-utf8 git make curl xz-utils file -y
!pip install mecab-python3==0.7
import pandas as pd
import numpy as np
import keras
import os
import warnings
warnings.simplefilter('ignore')
import subprocess
import mojimoji
import re
import MeCab
import matplotlib.pyplot as plt

from keras.layers import Dense, Dropout, LSTM, Embedding, BatchNormalization
from keras.layers.wrappers import Bidirectional
from keras.callbacks import EarlyStopping, ModelCheckpoint
from keras import Input, Model, utils
from keras.preprocessing.sequence import pad_sequences
from keras.callbacks import EarlyStopping

from text_vectorian import SentencePieceVectorian
from keras_self_attention import SeqSelfAttention
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report

データの前処理

# データの読み込み
app = pd.read_csv('data/app.csv') # 承認データ
disapp = pd.read_csv('data/disapp.csv') # 不承認データ

app['target'] = 'app' # targetにlabelを代入する
disapp['target'] = 'disapp'

# 今回は、titleとdescriptionを用いて予測するので、それら二つの変数を一つにまとめる
app['sentence'] = app['title'] + app['description'] 
disapp['sentence'] = disapp['title'] + disapp['description']

# これまで別々に処理していたappとdisappをまとめてdfとする
df = app.append(disapp)

今回のデータは特殊で、承認・不承認は商品ごとについているラベルではなくアカウントと紐づいたラベルとなっています。それらを各商品と承認・不承認が紐づいているとして各商品ごとに予測することを行ってます。

ここで注意が必要なのは、データの分割方法です。アカウントを無視してデータを分割してしまうとリークを起こす可能性があります(リークとは、本来予測では使用できないデータが学習時に入ってしまっていることです)。

そのため同じアカウントのデータが訓練データ、検証データ、テストデータに渡って存在しないようにしなければなりません

例えば、アカウントAの商品データは全て訓練データとする,アカウントBの商品データは全てテストデータにするといったようなことを意味しています。

アカウントごとにデータを分割するには、各アカウントごとの商品数がある程度同じである方がlabelが不均衡にならないと考え、データ数を揃える処理を施しました。 (これらはGroupKFoldを使用すれば解決できると考えられますが、分析実施時はGroupKFoldを知らなかった)

# アカウントごとに商品数が異なるので50以上商品数がある場合は50までの商品を使用する
# アカウントごとに商品数を揃えることで、labelが不均衡になることを緩和している
# アカウントごとにlabelがふられるが、商品ごとに予測結果を出す時のみ実施
cutted_df = pd.DataFrame([])
for acc in df.account_name.unique():
  data = df[df.account_name == acc]
  if data.shape[0] > 50: 
    data = data[:50]
  cutted_df = pd.concat([cutted_df,data],0)  

df = cutted_df.sample(frac=1,random_state=1).reset_index(drop=True)

次は、データの前処理についてです。

自然言語処理の前処理で有効と言われている半角->全角、数字は全て0にする、スペース文字の消去を行いました。 また、これまでlabelが'app'または'disapp'だったのでそれらを入力できる形式に変換しています。

def PreprocessData(df,dirname):
  # データの前処理関数
  # 辞書型を返す

  mecab = MeCab.Tagger('-Ochasen')

  # textデータの前処理
  df = TextPreprocess(df)

  label2index = {k: i for i, k in enumerate(df.target.unique())}
  index2label = {i: k for i, k in enumerate(df.target.unique())}

  class_count = len(label2index)
  labels = utils.to_categorical([label2index[label] for label in df.target], num_classes=class_count)

  features,sentences,vectorian,account = MakeFeatures(df)

  return {
      'class_count': class_count,
      'label2index': label2index,
      'index2label': index2label,
      'labels': labels,
      'features': features,
      'sentences':sentences,
      'input_len': vectorian.max_tokens_len,
      'vectorian':vectorian,
      'account':account
  }

def TextPreprocess(df):
  for i in df.index:
    sen = df.loc[i,'sentence']
    sen = mojimoji.han_to_zen(sen)
    sen = re.sub(r'\d+','0',sen)
    df.loc[i,'sentence'] = sen.replace('\u3000','')
  return df

def MakeFeatures(df):
  vectorian = SentencePieceVectorian()

  features = []
  sentences = []
  accounts = []
  for feature,account in zip(df['sentence'],df['account_name']):
    f = vectorian.fit(feature).indices
    features.append(f)
    sentences.append(feature)
    accounts.append(account)

  features = pad_sequences(features, maxlen=vectorian.max_tokens_len)

  return features,sentences,vectorian,accounts

では、ここまでの前処理を流します。

data = PreprocessData(df,dirname) # dirnameは、出力結果などを入れたいpath入れてください

次はtrain_test_splitを行いますが、先ほども記述した通り通常の手法ではリークするので、以下のようにしました。 (上述の通りGroupKFoldの実施で回避できる)

def CollectData(data,account):
  features = []
  sentences = []
  labels = []
  
  for ac in account:
    where_ = np.where(np.array(data['account']) == ac)
    features.extend(np.array(data['features'])[where_])
    sentences.extend(np.array(data['sentences'])[where_])
    labels.extend(np.array(data['labels'])[where_])
  return np.array(features),np.array(sentences),np.array(labels)

train_account,test_account = train_test_split(list(set(data['account'])),test_size=0.2,random_state=1)
train_account,val_account = train_test_split(train_account,test_size=0.25,random_state=1)

train_features,train_sen,train_labels = CollectData(data,train_account)
val_features,val_sen,val_labels = CollectData(data,val_account)
test_features,test_sen,test_labels = CollectData(data,test_account)

通常のデータセットであれば、以下のようにすることでデータの分割が行えます。

(train_features,val_features,
 train_labels,val_labels,
 train_sen,val_sen) = train_test_split(data['features'], data['labels'], data['sentences'], test_size=0.2, random_state=1)

(train_features,test_features,
 train_labels,test_labels,
 train_sen,test_sen) = train_test_split(train_features, train_labels, train_sen, test_size=0.25, random_state=1)

ここまででデータの整形が完了です。

学習

次は、モデルの定義を行います。

def _create_model(input_shape, hidden, class_count,vectorian):
    input_tensor = Input(input_shape)
    common_input = vectorian.get_keras_layer(trainable=True)(input_tensor)
    x1 = SeqSelfAttention(name='attention')(common_input)
    x1 = Bidirectional(LSTM(hidden))(x1)
    x1 = Dropout(0.5)(x1)
    x1 = Dense(32)(x1)
    x1 = Dropout(0.5)(x1)
    x1 = Dense(16)(x1)
    x1 = Dropout(0.5)(x1)
    output_tensor = Dense(class_count, activation='softmax', name='class')(x1)

    model = Model(input_tensor, output_tensor)
    model.compile(loss='categorical_crossentropy', optimizer='nadam', metrics=['acc'])

    return model

hidden = 356
model = _create_model(train_features[0].shape, hidden, data['class_count'],data['vectorian'])
model.summary()

作成したモデルにデータを流して学習を進めます。

model_filename='{0}/model.h5'.format(dirname)

history = model.fit(train_features, train_labels,
                    epochs=50,
                    batch_size=32,
                    validation_data=(val_features, val_labels),
                    shuffle=False,
                    callbacks = [
                        EarlyStopping(patience=5, monitor='val_acc', mode='max'),
                        ModelCheckpoint(filepath=model_filename, monitor='val_acc', mode='max', save_best_only=True)
                    ])

評価・出力

ModelCheckpointで保存したmodelを読み取り、さらにSelf-Attentionの結果を得られるようにします。

from keras.models import load_model
model = load_model(model_filename, custom_objects=SeqSelfAttention.get_custom_objects())
model = Model(inputs=model.input, outputs=[model.output, model.get_layer('attention').output])

modelにtest dataを入れて結果を取得します。

out = model.predict(test_features)

y = out[0] # 予測labelのsoftmaxが入っている
weight = out[1] # Self-Attentionのweighが入っている

pred = np.argmax(y,1) # 予測値
max = np.max(y,1) # 信頼値

df_y = pd.DataFrame(np.array([np.argmax(test_labels,1),pred,max*100]).T,columns=['true','pred','trust']) # 結果をまとめておくと精度確認に使える

精度の確認を行います。

ただ、testを入力し得られた結果を出力するだけでは精度が得られなかったので、信頼値が高いものだけを選別し、出力するようにしました。 信頼値を90~55の間で出力し、最もAccuracyが高い時の信頼値以上のものを出力としました。

一方で信頼値を上げすぎるとわずかな出力しか得られないので、元のtestデータ数の1/3はデータ数が出力として確保できるような条件を加えました。

report = classification_report(pred, np.argmax(test_labels,1),output_dict=True,target_names=[data['index2label'][i] for i in [0,1]])
FirstReport_df = pd.DataFrame(report).T

print(FirstReport_df)
FirstReport_df.to_csv(dirname+'NotCutReport.csv')

# 信頼値が高い予測だけを出力とすることで確からしいものだけをみる
AppSupport = FirstReport_df.loc['app','support'] # 予測した数を取得
DisappSupport = FirstReport_df.loc['disapp','support']

for UpperLimit in range(90,55,-1):
  max_acc = 0
  for i in range(50,UpperLimit,1):
    df_y_cut = df_y[df_y.trust > i]
    report = classification_report(df_y_cut.pred, df_y_cut.true ,output_dict=True)
    report_df = pd.DataFrame(report).T
    acc = report_df.loc['accuracy','precision']
    if max_acc < acc:
      max_acc = acc
      max_i = i
  df_y_cut = df_y[df_y.trust > max_i]
  report = classification_report(df_y_cut.pred, df_y_cut.true ,output_dict=True,target_names=[data['index2label'][i] for i in [0,1]])
  report_df = pd.DataFrame(report).T
  if (report_df.loc['app','support'] > HighSupport/3) and (report_df.loc['disapp','support'] > LowSupport/3):
    # 元の予測値の1/3のデータ数が確保できていればクリア
    print('UpperLimit:' + str(UpperLimit))
    print('max_i:' + str(max_i))
    print(report_df)
    report_df.to_csv(dirname+'Report.csv')
    break

最後にSelf-AttentionのWeightをcsvで出力します。

得られた出力結果は、予測値-承認と真値-承認、予測値-承認と真値-不承認、予測値-不承認と真値-承認、予測値-不承認と真値-不承認のように予測値と真値の結果に応じて4つに分けてcsvで出力するようになっています。

app_app = pd.DataFrame([])
app_disapp = pd.DataFrame([])
disapp_app = pd.DataFrame([])
disapp_disapp = pd.DataFrame([])

for i in range(len(test_features)):
  input_text = test_sen[i]
  tokens = data['vectorian'].tokenizer._tokenizer.encode_as_pieces(input_text)

  conf = out[0][i] * 100
  wei = out[1][i]

  if np.max(conf) <= max_i:
    continue

  pred = [data['index2label'][np.argmax(conf)]]
  labels = [data['index2label'][np.argmax(test_labels[i])]]

  weights = [w.max() for w in wei[-len(tokens):]]

  df = pd.DataFrame([tokens, weights], index=['token', 'weight']).T

  mean = np.asarray(weights).mean()
  for j in df.index:
    if df.loc[j,'weight'] - mean <= 0:
      df.loc[j,'weight'] = 0
    else:
      df.loc[j,'weight'] = df.loc[j,'weight'] - mean
  
  pred += df.token.values.tolist()
  labels += df.weight.values.tolist()

  final = pd.DataFrame(np.array([pred,labels]).T,columns=['pred',input_text])

  if (pred[0] == 'app') & (labels[0] == 'app'):
    app_app = pd.concat([app_app,final],1)
  elif (pred[0]  == 'app') & (labels[0] == 'disapp'):
    app_disapp = pd.concat([app_disapp,final],1)
  elif (pred[0]  == 'disapp') & (labels[0] == 'app'):
    disapp_app = pd.concat([disapp_app,final],1)
  elif (pred[0]  == 'disapp') & (labels[0] == 'diaspp'):
    disapp_disapp = pd.concat([disapp_disapp,final],1)

app_app.to_csv(dirname+'app_app.csv',index=False)
app_disapp.to_csv(dirname+'app_disapp.csv',index=False)
disapp_app.to_csv(dirname+'disapp_app.csv',index=False)
disapp_disapp.to_csv(dirname+'disapp_disapp.csv',index=False)

まとめ

Self-Attentionを用いて無料リスティングの不承認理由を解き明かそうと分析しました。 しかし、title,descriptionのみからは承認と不承認を分類することができず、不承認理由の解明には貢献できせんでした。

Self-Attentionとデータセットの相性が悪いという可能性も考えられるので、tfidf+lgbも試みましたが、こちらもうまくいきませんでした。やはりこちらの結果からもtitle,descriptionのみからは承認と不承認を分類することができないということが考えられます。