自然言語処理、教師あり学習（分類アルゴリズム）でハラスメント判定ツールを作ってみた

# はじめに
これまでのハラスメントの裁判事例を収集し、機械学習を使って通報案件（無毒化した内容）のハラスメント判定をしてみましたのでご紹介したいと思います。

# 開発環境
・Google Collaboratory

Google ColabでGoogle Driveをマウントし、Google Drive内のファイルにアクセスする処理を入れてみました。

```
from google.colab import drive
drive.mount('/content/drive')
```

# ライブラリのImport・データの読み込み

・必要なライブラリをimport
Pandas(データ読み込みライブラリ)、Numpy（数値計算用ライブラリ）､Matplotlib（データ可視化ライブラリ）､Janome（日本語形態素解析ライブラリ）、scikit-learn（機械学習ライブラリ）、nltk（自然言語処理ライブラリ）等をインストールしました。


```
!pip install Janome==0.3.7
!pip install --upgrade scikit-learn
!pip install nltk
import nltk
nltk.download('wordnet')
nltk.download('omw-1.4')


import pandas as pd
import numpy as np
import random
import matplotlib.pyplot as plt
import seaborn as sns
from janome.tokenizer import Tokenizer
import scipy
import re
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import SVC
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.model_selection import RandomizedSearchCV

```


・データの読み込み
Google Drive上にある保存してあるExcelファイルをpandasを使って読み込みます。このファイルには、訓練データとテストデータが含まれています。


```
# Load the Excel file　読み込み
file_path = '/content/drive//MyDrive/DoSA/dataset_0619.xlsx'
xl = pd.ExcelFile(file_path)

# Load the desired sheet into a DataFrame　ファイルの指定シートを読み込み
df1 = xl.parse('訓練ﾃﾞｰﾀ')
df2 = xl.parse('ﾃｽﾄﾃﾞｰﾀ')

train_data = df1["概要"]
train_labels =df1["ハラスメント判定"].astype(int)

test_data = df2["概要"]
test_labels = df2["ハラスメント判定"]
```


#  ﾃﾞｰﾀの前処理

```
from nltk.corpus import wordnet as wn
from janome.tokenizer import Tokenizer
import random


・データ拡張
def data_augmentation(text):
    t = Tokenizer()
    tokens = t.tokenize(text)
    new_text = ""
    for token in tokens:
        word = token.surface
        pos = token.part_of_speech.split(',')[0]
        if pos in ['名詞', '動詞', '形容詞', '形容動詞']:
            synonyms = []
            for syn in wn.synsets(word, lang='jpn'):
                for lemma in syn.lemmas('jpn'):
                    synonyms.append(lemma.name())
            if len(synonyms) > 0:
                word = random.choice(synonyms)
        new_text += word
    return new_text

df1['概要'] = df1['概要'].apply(data_augmentation)
df2['概要'] = df2['概要'].apply(data_augmentation)

#Tfid vecをvectorizerに代入する
vectorizer = TfidfVectorizer(tokenizer=tokenize2)#tokenizerは省略できない

#訓練データのトークン化・ベクトル化
train_matrix = vectorizer.fit_transform(train_data)
train_matrix = train_matrix.toarray()#配列のかたをnumpy型にかえる

random.seed(42)

#元の配列を取ってランダムに並べ替える
rand_index = np.random.permutation(np.arange(len(train_matrix)))
#print(rand_index)

#訓練データをランダムにシャッフルする処理
train_matrix = train_matrix[rand_index]
train_labels = train_labels[rand_index]

print(train_labels)

feature_names = vectorizer.get_feature_names_out()
for i in range(len(train_matrix)):
    tokens = [feature_names[j] for j in range(len(feature_names)) if train_matrix[i][j] > 0]
    print(f"トークン化結果 {i+1}: {tokens}")

print(train_matrix.shape)
```
↑のコードで行った処理のまとめ:
・janome.tokenizerを用いてテキストを分かち書き（トークナイズ）します。
・各トークン（単語）について、その品詞が名詞、動詞、形容詞、または形容動　詞のいずれかであるかどうかをチェック
・これらの品詞に該当する単語について、WordNetを用いて類義語（シノニム）  を探します。WordNetは、自然言語処理で使われる語彙データベースで、単語間  の意味関係（シノニム、アントニムなど）を含む
・類義語が存在する場合、その中からランダムに1つ選び、元の単語を置換
・これらの操作を全ての単語に対して行い、最終的に新しいテキストを生成
・TF-IDFを使ってテキストデータをベクトル化

以下がトークン化した結果
![画像2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237524/1f19fc11-3303-f79c-737b-62ca73915808.png)

ここまでの*①データ拡張、②テキストのトークン化、③ベクトル化*の処理は、テキストデータを機械学習モデルが扱える形式に変換し、その後の分析と予測のための準備を行うための重要なステップです。

これにより、テキストデータを機械学習モデルが理解できる形式に変換できました！

# モデル構築・予測

ここでは、少し古典的ではありますが、教師あり学習で分類ができる　'Support Vector Machine'（以下SVMとする）という名のアルゴリズムを使いたいと思います。

その後、訓練したモデルを使ってテストデータで予測を行い、その結果を評価しています。評価指標としては、正解率、精度、再現率、F1スコアを計算しました。
```
SVCモデルの初期化
model = SVC()

モデルの学習
model.fit(train_matrix, train_labels)

test_matrix = vectorizer.transform(test_data).toarray()
test_predictions = model.predict(test_matrix)
test_accuracy = accuracy_score(test_labels, test_predictions)
print("ﾃｽﾄﾃﾞｰﾀの正解率:", test_accuracy)

precision = precision_score(test_labels, test_predictions)
print("ﾃｽﾄﾃﾞｰﾀの精度:", precision)

recall = recall_score(test_labels, test_predictions)
print("ﾃｽﾄﾃﾞｰﾀの再現率:", recall)

f1 = f1_score(test_labels, test_predictions)
print("テストデータのF1スコア:", f1)
result = {}
for text, pred, correct in zip(test_data, test_predictions, test_labels):
  if result.get(f"{correct}-{pred}", None)==None:
     result[f"{correct}-{pred}"] = [text]
  else:
     result[f"{correct}-{pred}"].append(text)

for key in result:
  print(key)
  for text in result[key]:
    print(text)
  print()
```

【予測の計算結果】
![画像3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237524/3a62b807-45ae-f8ad-0324-40ae43dba179.png)

指標と数値だけを見ても理解しづらいと思うので、日本語で解説します。

**正解率(Accuracy)**: ハラスメントと非ハラスメントの両方を正しく予測できる割合を示しています。

**適合率(Precision)**: ハラスメントと予測したもののうち、実際にハラスメントだった割合を示します。つまり、モデルがハラスメントと予測する能力の精度を示しています。

**再現率(Recall)**: 実際のハラスメントのうち、ハラスメントと正しく予測できた割合を示します。つまり、ハラスメントを見逃さない（偽陰性を避ける）能力を示しています。

**F1スコア(F1 Score)**: 適合率と再現率の調和平均を示します。適合率と再現率のバランスをとる指標で、この二つの指標のバランスを考慮した評価指標になります。

ハラスメント判定の場合、ハラスメントを見逃すこと（偽陰性）の影響が大きいですよね。ハラスメント事例をハラスメントに該当しない、と判定してしまったら大変なことになりますよね。。。そのため、再現率が高いモデルが最適化と思いがちですが、一方で誤って非ハラスメントをハラスメントと予測する（偽陽性）ことも問題ですよね。。

したがって、適合率と再現率のバランスをとるF1スコアが最適かどうかを見ていくことがポイントになると考えました。


# 予測結果の可視化
それでもやはり↑の予測結果の数字だけでは分かりにくいので、混同行列(confusion matrix)を作成して可視化してみました。

```
cm = confusion_matrix(test_labels, test_predictions)
class_labels = ['Non-Harassment', 'Harassment']
sns.heatmap(cm, annot=True, cmap='Blues', fmt='g', xticklabels=class_labels, yticklabels=class_labels)

plt.title('Confusion Matrix')
plt.xlabel('Predicted Label')
plt.ylabel('True Label')
plt.show()

```

![画像1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237524/5ee4da78-baf9-6f58-9874-4d804097c4b6.png)

図が可視化できたわけですが、ハラスメント・非ハラスメントと正しく判定できているのは14件中8件､残りの6件は誤判定だが、右上の4件はハラスメントに該当する可能性が少しでもあるものはハラスメントとカテゴライズしているので、この判定が大きなリスクになることはないでしょう。むしろ左下の2件は、実際にはハラスメントに該当するが、該当しない、と判定してしまっているので、要注意ですね。。。2となっているところは、0であることが望ましいですね。

# ハイパーパラメーターのチューニング
上記の予測結果をもう少し改善できないか吟味するために、RandomizedSearchCVを使ってSVMモデルのハイパーパラメーターのチューニングを行いました。これにより、モデルの性能を最大化するパラメーターの組み合わせを見つけ出します。

```
from sklearn.svm import SVC#直線を引くアルゴリズム
from sklearn.model_selection import RandomizedSearchCV
from sklearn.metrics import recall_score
import scipy

model_param_set_random =  {SVC(): {
        "kernel": ["linear", "poly", "rbf", "sigmoid"],
 "C": scipy.stats.uniform(0.00001, 300)
 "decision_function_shape": ["ovr", "ovo"],
 "random_state": [42]}}

max_score = 0
best_param = None

ランダムサーチでハイパーパラメーターを探索
for model, param in model_param_set_random.items():
    clf = RandomizedSearchCV(model, param, scoring='recall')
    clf.fit(train_matrix, train_labels)
    pred_y = clf.predict(test_matrix)
    score = recall_score(test_labels, pred_y)
    if max_score < score:
        max_score = score
        best_param = clf.best_params_

print("ハイパーパラメーター:{}".format(best_param))
print("ベストスコア:",max_score)

# 最適なパラメータでモデルを再構築
best_model = SVC(**best_param)
best_model.fit(train_matrix, train_labels)
pred_y_best_model = best_model.predict(test_matrix)

print("\n調整後の評価指標は以下の通り")
print('正解率:', accuracy_score(test_labels, pred_y_best_model))
print('適合率:', precision_score(test_labels, pred_y_best_model, average='macro'))
print('再現率:', recall_score(test_labels, pred_y_best_model, average='macro'))
print('F1スコア:', f1_score(test_labels, pred_y_best_model, average='macro'))

# テストデータの予測結果を出力
result = {}
for text, pred, correct in zip(test_data, pred_y, test_labels):
  if result.get(f"{correct}-{pred}", None)==None:
     result[f"{correct}-{pred}"] = [text]
  else:
     result[f"{correct}-{pred}"].append(text)

for key in result:
  print(key)
  for text in result[key]:
    print(text)
  print()

```
![画像4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237524/2c9ec42a-9984-b460-62e4-35b0b181bb34.png)


# 混合行列で再び可視化

```
from sklearn.metrics import confusion_matrix

cm = confusion_matrix(test_labels, test_predictions)
class_labels = ['Non-Harassment', 'Harassment']

sns.heatmap(cm, annot=True, cmap='Blues', fmt='g', xticklabels=class_labels, yticklabels=class_labels)

plt.title('Confusion Matrix')
plt.xlabel('Predicted Label')
plt.ylabel('True Label')
plt.show()
```
ヒートマップの色はお好みに合わせて変更できますのでやってみて下さい。

![画像1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237524/cd34ea94-3eba-dde9-fbb4-e91016e17cb9.png)

予測結果はチューニング前と変わりませんでした。やはりデータが少ないのでしょうね。。

# モデルと性能指標の保存
このセルでは、訓練したモデルとその性能指標をpickle形式でGoogle Driveに保存します。これにより、後からモデルの再利用や性能確認が可能になります。

```
# 学習モデルを保存
with open(model_path, 'wb') as f:
    pickle.dump(pred_y_best_model, f)

performance = {
    'precision': precision,
    'recall': recall,
    'f1': f1
}

performance_path = '/content/drive/MyDrive/performance.pkl'
with open(performance_path, 'wb') as f:
    pickle.dump(performance, f)
print("学習モデルを保存しました。")

```

# 最後に

長々とお付き合いいただきありがとうございました。

機械学習は**データ収集と前処理が何よりも大切**だと言われますが、その言葉通り、「ハラスメントに該当しない」事例をHPから収集するのが非常に大変でした。。。
該当する事例はネット上にたくさんありましたが、該当しない事例を探すのに非常に苦労しました。。。

今後はもう少し前もって作りたいツールに必要なデータの収集を前広に進めておきたいと思います。

やはり学習データをそこまで集められなかったので、改善の余地満載の機械学習になってしまった感があります。とはいえ、初めて教材などを読みながらこのようなツールを作れたことは今後よりよいものを作っていくための自信につながりました。大変な部分もありながらも、最終的には楽しみながら手を動かせたのはよかったです！
作ってみて初めて分かることもいっぱいありますね。

次回はRandom ForestやTransformerベースのモデルを使って予測結果を計算してみたいと思います！（_To be continued_)

自身が誰かから受けた仕打ちはハラスメントかもしれないが、自信が持てないから泣き寝入りしてしまいがちな人がこうしたツールで試しに一次判定できれば、speak upする根拠材料になるかもしれない、という一心で実装してみました。

どうかみなさまが心理的安全性の高い職場環境で気持ちよく働けますように応援しております！

おしまい

#  参考資料
1.	厚生労働省：ハラスメントに認定された事例・認定されなかった事例
裁判例を見てみよう｜あかるい職場応援団 -職場のハラスメント(パワハラ、セクハラ、マタハラ)の予防・解決に向けたポータルサイト
https://www.no-harassment.mhlw.go.jp/foundation/judicail-precedent/

2.	（新版）_パワーハラスメントに関する主な裁判例の分析
https://www.mhlw.go.jp/content/11909500/000548187.pdf

3.	パワハラの裁判例｜パワハラ認定された例＆されなかった例 | ストレスチェックレポート 
https://stresschecker.jp/%e3%83%91%e3%83%af%e3%83%8f%e3%83%a9%e3%81%ae%e8%a3%81%e5%88%a4%e4%be%8b%ef%bd%9c%e3%83%91%e3%83%af%e3%83%8f%e3%83%a9%e8%aa%8d%e5%ae%9a%e3%81%95%e3%82%8c%e3%81%9f%e4%be%8b%ef%bc%86%e3%81%95%e3%82%8c/