この記事はデジクリ Advent Calendar 2025 1日目の記事です。デジクリは芝浦工業大学の創作サークルです。デジクリについて、詳しくは芝浦工業大学 デジクリをご覧ください。
みなさんこんにちは!21st の newt(@newt239)です。2025 年度のデジクリアドベントカレンダーで 1 日目を務めさせていただきます。どうぞよろしくお願いいたします。
最近はサークルで稼働させているMattermostをコネコネして遊ぶのがマイブームで、ここ1ヶ月はよくMattermostのAPIと戯れております。ところでこれも数週間前、某氏から「2人のtimes投稿データを集め、どちらの投稿であるかを判別する分類器を作った」というエピソードを聞きました。なるほど、これは面白そうだと思い、ちょうどアドカレのネタを考えていたタイミングでもあったため、自分でもやってみることにしました。今回はこの分類器を作ってみたというお話です。
なお、筆者は機械学習については門外漢です。昨年度東大松尾研の GCI を履修していたくらいで、それ以上の知識については理解が怪しいです。読者の方々におかれましてはその点をご承知おきの上、読み進めていただければと思います。私が誤って理解していそうな記述があれば、こっそり教えていただけますと幸いです🙏
データを用意する
デジクリでは部内のコミュニケーションツールとして Mattermost を導入しており、ここでは毎日多くの部員が活発にメッセージを投稿しています。
今年は特に新入部員が多かったこともあり大量の times が作られていて、平均して 1 日に 1,000 件くらいのメッセージが投稿されています*1。

その中でも今回は、部長であるロペくん(@Ropera_88)に協力を仰ぎ、私のtimesとロペくんのtimesに投稿された本人の投稿を抽出し、それらがどちらの投稿であるかを判別する分類器を作ることにしました。なお、データの収集にあたりロペくん本人からは投稿データの利用についてご快諾いただいております。感謝!
さて、まずはデータセットとなる 2 人の投稿を取得します。2 人とも様々なチャンネルでメッセージを投稿していますが、チャンネルごとにトーンが異なる場合があるため、今回はそれぞれの times に投稿された本人のメッセージをデータセットとして収集したいと思います。
Mattermost の API Documentation を確認します。チャンネルのメッセージを取得するエンドポイントを探すと、Get posts for a channel というページがありました。ページネーションが行われているようなので、per_page にほどほどの数を指定して、あとはレスポンスの配列に含まれる個数がこの数より小さくなるまで for を回せば、すべてのメッセージが取得できそうです。
参考までに私のチャンネルの取得結果の末尾です。タイムスタンプと投稿者のユーザー名、本文を取得してきました。

スクリプトを走らせた 11 月 10 日時点で、私のチャンネルには 3,460 件、ロペくんのチャンネルには 2,787 件の投稿がありました。
次に、チャンネルにある投稿のうち、本人の投稿のみに絞り込みます。ついでに分かれていた 2 つの CSV ファイルを結合して上げる処理を Python で書き、merged-posts.csv を生成しました。このファイルのレコード数は 5,566 件で、これが真にデータセットとして使える 2 人の投稿の総数です。
学習させる
from sentence_transformers import SentenceTransformer import torch import numpy as np device = "cuda" if torch.cuda.is_available() else "cpu" model_name = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" st_model = SentenceTransformer(model_name, device=device) embeddings = st_model.encode( texts, batch_size=32, show_progress_bar=True, convert_to_numpy=True, )
次にこれらのデータを学習したモデルを作る作業に入ります。今回は文脈や表現を捉えた学習を行ってほしいので、日本語に対応している事前学習モデルを活用します。各投稿テキストをトークナイズし、ベクトルを固定の長さに変換します。
今回学習及び推論はすべてGoogle Colaboratoryで実施するため、計算資源が限られています。そこでまず、 Hugging Faceで配布されている multilingual MiniLM という多言語に対応したモデルを使用することにしました。
from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42, ) clf = LogisticRegression( max_iter=1000, n_jobs=-1, ) clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print(classification_report(y_test, y_pred, target_names=le.classes_))
余談ですが、サンプルコードを見ているとよく random_state=42 という数字を目にします。この数字の意味を調べたところ、どうやらとあるSF小説に由来しているようです。
実行結果は以下のとおりでした。
| precision | recall | f1-score | support | |
|---|---|---|---|---|
| newt239 | 0.72 | 0.75 | 0.73 | 622 |
| ropera_88 | 0.66 | 0.62 | 0.64 | 487 |
| accuracy | 0.69 | 1109 | ||
| macro avg | 0.69 | 0.69 | 0.69 | 1109 |
| weighted avg | 0.69 | 0.69 | 0.69 | 1109 |
正答率を表すAccuracyは 69% でした。
任意の文章で推論させてみる
具体的にどんな文章が誰の投稿だと判定されているか見てみたいため、任意の文字列を与えることでそれがどちらの投稿であるかを出力する関数を用意します。
import joblib import numpy as np clf = joblib.load("/content/mm_user_classifier.joblib") le = joblib.load("/content/mm_user_label_encoder.joblib") def predict_user(text: str): x = st_model.encode([text], convert_to_numpy=True) pred_id = clf.predict(x)[0] pred_user = le.inverse_transform([pred_id])[0] proba = clf.predict_proba(x)[0][pred_id] if hasattr(clf, "predict_proba") else None return pred_user, proba
predict_user 関数の引数に文章を与えて呼び出してあげれば、どちらの投稿であるか、その信頼度はどのくらいかを出力してくれます。
試しに「シス工」と入力してみたところ、以下のような結果になりました。シス工はロペくんの学部で開講されている科目のことで、課題が大変なことで有名らしいです。
predict_user("シス工") ### output ### ('ropera_88', np.float64(0.6557758160921019))
65%の信頼度で、ロペくんの投稿であるという判定が出力されました。本来は文章単位で入力するほうが良いでしょうが、少なくとも私が発言する単語である可能性は実際限りなく低いため、この結果だけを見れば我々の感覚に沿ったものとなっています。
精度を上げる
まだほとんど調整をかけていませんが、70% 程度の正答率でした。ここからはもっと精度を上げられないか、まずはデータセットを眺めながら考えていきます。
最初に気になるのが極端に文字数の少ないメッセージです。例えば「あ」とか「うわ」みたいな短い反応は、誰が書いても同じようになりがちで、判別の手がかりになりません。そこで、3文字以下の投稿を除外することにします。またあわせて、3 文字は超えるもののリアクションしか含まれない投稿についても除外します。これは「最初と最後の文字が : かつその間に英字しかない」という条件で判定しようと思います。
また、他の投稿を参照して言及しているメッセージは文体が変わることがあるため、新しい特徴量として切り出します。Mattermost では https://${MattermostのURL}/pl/${メッセージ ID} という形式で保存されているため、このリンクを削除し、新たに is_quote_post というカラムを追加します。
最後に、これ以外の URL については完全に除去します。
これらの整形を加えたところ、データセットのレコード数は 5,295 件になりました。そして結果は以下の通り。
| precision | recall | f1-score | support | |
|---|---|---|---|---|
| newt239 | 0.73 | 0.75 | 0.74 | 597 |
| ropera_88 | 0.67 | 0.64 | 0.66 | 462 |
| accuracy | 0.71 | 1059 | ||
| macro avg | 0.70 | 0.70 | 0.70 | 1059 |
| weighted avg | 0.70 | 0.71 | 0.70 | 1059 |
正答率が 69% から 71% に向上しました。
交差検証
次に交差検証を行ってみます。交差検証はデータセットを複数のサブセットに分割し、それぞれのサブセットでモデルを学習させて、その平均性能を評価する方法です。
これまでは train_test_split でデータを 1 回だけ訓練用とテスト用に分割していましたが、この方法だとたまたまそのデータの分け方で良い結果が出ただけかもしれません。交差検証を使うことで、より信頼性の高い評価ができます。
今回は StratifiedKFold という手法を使います。これはデータを K 個のグループに分け、各クラスの比率を保ったまま分割してくれる優れものです。今回は 5 分割で実施します。
from sklearn.model_selection import StratifiedKFold, cross_val_score cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(clf, X, y, cv=cv, scoring='accuracy') print("Cross-validation scores:", scores) print(f"Average score: {scores.mean():.2f}") print(f"Standard deviation: {scores.std():.2f}")
実行結果は以下の通りです。
Cross-validation scores: [0.68177526 0.70915958 0.700661 0.6789424 0.70632672] Average score: 0.70 Standard deviation: 0.01
5 回の評価で精度は約 0.68 〜 0.71 の範囲に収まり、平均で 70%という結果になりました。標準偏差が 0.01 と小さいことから、モデルの性能が安定していることがわかります。
より高性能なモデルを試す
70% の精度も悪くはないのですが、せっかくなのでもう少し精度を上げられないか試してみます。これまで使っていた multilingual MiniLM は多言語対応で軽量ですが、今回のデータセットは主に日本語です。そこで、日本語専用に学習されたモデルを使えば精度が向上する可能性があります。
今回は pkshatech/GLuCoSE-base-ja-v2 というモデルを試してみます。GLuCoSE(General Language understanding and Contrastive Sentence Embedding)は、PKSHA Technology が開発した日本語の文埋め込みモデルです。
前回のコードベースのうち、 model_name のみを変更して再度実行します。
model_name = "pkshatech/GLuCoSE-base-ja-v2" st_model_ja = SentenceTransformer(model_name, device=device) embeddings_ja = st_model_ja.encode( texts, batch_size=32, show_progress_bar=True, convert_to_numpy=True, ) X_ja = embeddings_ja print(f"Embeddings shape: {embeddings_ja.shape}") X_train, X_test, y_train, y_test = train_test_split( X_ja, y, test_size=0.2, stratify=y, random_state=42, ) clf_ja = LogisticRegression(max_iter=1000, n_jobs=-1) clf_ja.fit(X_train, y_train) y_pred = clf_ja.predict(X_test) print(classification_report(y_test, y_pred, target_names=le.classes_))
実行結果は以下のとおりです。
| precision | recall | f1-score | support | |
|---|---|---|---|---|
| newt239 | 0.75 | 0.79 | 0.77 | 597 |
| ropera_88 | 0.71 | 0.66 | 0.68 | 462 |
| accuracy | 0.73 | 1059 | ||
| macro avg | 0.73 | 0.72 | 0.73 | 1059 |
| weighted avg | 0.73 | 0.73 | 0.73 | 1059 |
また、こちらも同様に交差検証を実施したところ、結果は以下の通りでした。
Cross-validation scores: [0.74787535 0.75354108 0.72521246 0.7082153 0.73276676] Average score: 0.7335 Standard deviation: 0.0162
日本語特化モデルを使うことで、精度が向上したことが確認できました!
multilingual MiniLM では平均精度が 70% だったのに対し、GLuCoSE では 73.35% まで向上したため、約 3% の改善です。また、precision(適合率)と recall(再現率)も全体的に向上しており、 newt239 の方は f1-score が 0.74 から 0.77 に上がっています。
標準偏差については 0.01 から 0.0162 へ増えましたが、これは平均精度(0.7335)の約 2.2% に相当し、依然として非常に良好な値です。一般的に、交差検証における標準偏差は平均精度の 5-10% 以下であれば安定していると判断されるらしいです。
実際に判定してみる
それでは、学習したモデルを使って実際にいくつかの文章を判定してみましょう。先ほど実装した predict_user 関数を更新し、データ取得後に投稿された2人のtimesの投稿をテストケースとして判定してみます。
test_cases = [
"昨日の夜Googleマップでつくばまでの時間調べたら2時間50分って言うから「そんな遠かったっけなぁ......🤔」と思いながら朝起きて駅まで来たのに、Yahoo乗り換え見たら2時間で着くって出て睡眠時間返せ😡の気持ち",
"シス工余裕できたらアニメーション描いて導入してビルドして寝ます",
"俺の戦いが、終わった......",
"やりたいこと!!!いっぱい!!あるのに!!時間が!!ないよ!!!",
"それはそうとグラクロです ご査収ください",
"ほえ〜 面白そう",
"半分以上分からねぇ",
]
print("=" * 60)
print("判定結果")
print("=" * 60)
for text in test_cases:
user, confidence = predict_user(text)
print(f"テキスト: \"{text}\"")
print(f" → 予測: {user} (信頼度: {confidence:.2%})")
print()
実行結果は以下の通りでした。
============================================================ 判定結果 ============================================================ テキスト: "昨日の夜Googleマップでつくばまでの時間調べたら2時間50分って言うから「そんな遠かったっけなぁ......🤔」と思いながら朝起きて駅まで来たのに、Yahoo乗り換え見たら2時間で着くって出て睡眠時間返せ😡の気持ち" → 予測: newt239 (信頼度: 99.48%) テキスト: "シス工余裕できたらアニメーション描いて導入してビルドして寝ます" → 予測: ropera_88 (信頼度: 97.30%) テキスト: "俺の戦いが、終わった......" → 予測: ropera_88 (信頼度: 90.57%) テキスト: "やりたいこと!!!いっぱい!!あるのに!!時間が!!ないよ!!!" → 予測: ropera_88 (信頼度: 91.09%) テキスト: "それはそうとグラクロです ご査収ください" → 予測: newt239 (信頼度: 95.47%) テキスト: "ほえ〜 面白そう" → 予測: newt239 (信頼度: 97.19%) テキスト: "半分以上分からねぇ" → 予測: ropera_88 (信頼度: 78.36%)
最後のメッセージについては私が投稿したものなので不正解ですが、それ以外はすべて正解です。結構微妙な投稿も選んだつもりだったんですが、意外と正しく判定できるものなんだなぁという印象を受けました。
まとめ
というわけで、Mattermost の投稿から 2 人の投稿を判別する分類器を作ってみました。
データの前処理で短すぎる投稿を除外したり、交差検証を行いつつモデルを変更したりと、基本的なことをやるだけでもそれなりに精度が出るんだなという感想です。
身近なデータで学習・推論させるとかなり面白いです。みなさんもぜひ、自分が持っている何らかのデータを使って試してみてください。
明日は 21st たここくんの、『【たここ言語】カードゲーム制作に使えるプログラミング言語を考えました』です。お楽しみに!
*1:実はMattermost上の投稿を可視化するツールも開発中です。気力が残っていればこれもアドカレ記事になるかもしれません