0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM で大量生成される偽アカウント・偽レビュー・スパムへの対策:文章の判定をやめ、つながりと時間の偏りで見つける

0
Posted at

レビュー欄・コメント欄・Q&A・会員登録といった「利用者が書き込める場所」を持つサービスでは、LLM によって偽の投稿を作るコストが大きく下がりました。以前のスパムは同じ文面の使い回しや不自然な日本語で見分けられましたが、LLM は 1 件ずつ違う、自然な文章を書けます。

この記事では、運営側がこの変化にどう備えるかを整理します。結論を先に書くと、「AI が書いた文章かどうか」を判定する方向には進まず、アカウント同士のつながりと投稿の時間の偏りで見つけ、対処は削除だけでなく表示の保留や重みの調整で段階的に行う、という組み立てです。検証コードは Python 3.13.17(標準ライブラリのみ)で動かしました。データはすべて模擬データで、LLM が書き分けた文面は固定の文字列で与えています。

公開資料から見える変化

大手プラットフォームの公開資料では、偽レビューは「一部の不正」ではなく、数億件規模で処理する対象になっています。

  • Google は 2025 年 4 月の発表で、2024 年に Google マップ上でポリシー違反のレビューを 2 億 4,000 万件以上ブロック・削除し、偽のビジネスプロフィールを 1,200 万件以上削除・ブロックしたと公表しています。不審な高評価レビューの急増を検知して削除した場合に利用者へ警告を出す仕組みや、投稿から時間が経ったレビューも AI で見直す運用にも触れています
  • Amazon は 2026 年 4 月の報告で、2025 年に「数億件」の偽レビューの疑いがある投稿を掲載前にブロックしたとしています。正確な件数は出していません
  • Meta の脅威対策の責任者は 2024 年 5 月の記者説明で、生成 AI を使った工作は高度な使われ方をしておらず、内容ではなく振る舞いに着目する既存の対策が効いていると述べています

規制側も AI による生成を明示的に対象にしています。米連邦取引委員会(FTC)の消費者レビュー規則(16 CFR Part 465、2024 年 10 月 21 日施行)は偽レビューの作成・売買を禁じ、官報掲載の説明で AI が生成したレビューも対象になると書いています。FTC は 2025 年 12 月 22 日に、この規則に違反するおそれのある 10 社へ警告状を送りました(違反 1 件あたり最大 53,088 ドルの民事制裁金の可能性を示しています)。日本でも 2023 年 10 月 1 日から、事業者の広告であることを隠した表示(いわゆるステルスマーケティング)が景品表示法の不当表示として扱われています。

運営側にとっての意味は、偽の投稿を放置すると利用者の信頼だけでなく、出品者や広告主との関係、規制対応にも影響しうるということです。

「AI が書いたか」で判定しない理由

LLM で作られた偽投稿への対策として、まず思いつくのが AI 生成テキストの判定器です。しかしこれを主軸にするのは勧めません。理由は 3 つあります。

1 つ目は、判定の精度が実務の要求に届かないことです。OpenAI は 2023 年 1 月に AI 生成テキストの判定器を公開しましたが、評価用データで AI の文章を見分けられたのは 26%、人の文章を誤って AI と判定したのが 9% でした。同社は 2023 年 7 月 20 日に、精度の低さを理由に提供を終えています。

2 つ目は、言い換えで簡単に回避されることです。Sadasivan らの研究(arXiv:2303.11156)は、別の LLM で繰り返し言い換えるだけで、電子透かし・学習型・ゼロショット型・検索型の各種判定器の検出率が大きく下がることを示し、人と AI の文章の分布が近づくほど、どんな判定器でも性能に理論的な上限がかかることを論じています。

3 つ目は、そもそも「AI が書いたこと」は違反ではないことです。実際に商品を使った人が、文章を整えるのに LLM を使うことは普通にあります。守りたいのは「体験していない人の評価」「同じ人が複数のアカウントで書いた評価」「対価を隠した評価」が混ざらないことで、文章の書き手が人か AI かではありません。判定器で AI らしい文章を消すと、正当な利用者の投稿を消し、偽アカウントの運営者は言い換えて残る、という逆の結果になりかねません。

文面の近さは「使い回し」には効き、「書き分け」には効かない

文面の重複検知が無意味になったわけではありません。テンプレートの一部を差し替えただけの安価なスパムは今も多く、これは文字 n-gram の Jaccard 係数のような単純な手法で束ねられます。一方で、LLM が 1 件ずつ書き分けた文面には効きません。これを確かめます。

text_similarity.py
"""文面の近さで偽レビューを束ねる方法が、テンプレート型と言い換え型でどう効くかを比べる。

言い換え型の文面は、LLM が 1 件ずつ書き分けた出力を想定した固定の文字列。
"""
from itertools import combinations


def shingles(text: str, n: int = 3) -> set[str]:
    t = "".join(text.split())
    return {t[i:i + n] for i in range(len(t) - n + 1)}


def jaccard(a: set[str], b: set[str]) -> float:
    return len(a & b) / len(a | b)


# テンプレートの一部を差し替えただけのスパム
template = [
    "最高の商品です!届くのも早くて大満足。また買います。星5つ!",
    "最高の商品です!届くのも早くて大満足。家族にも勧めます。星5つ!",
    "最高の商品です!梱包も丁寧で大満足。また買います。星5つ!",
    "最高の商品です!届くのも早くて大満足。リピート確定。星5つ!",
]

# 同じ依頼(この加湿器をほめる)から 1 件ずつ書き分けた想定の文面
paraphrased = [
    "寝室で使っていますが、朝起きたときの喉の乾燥がかなり楽になりました。音も静かです。",
    "タンクが大きいので一晩つけっぱなしでも水切れしません。デザインもすっきりしていて気に入っています。",
    "子どもの部屋用に購入。お手入れが簡単で、フィルター交換の目安がわかりやすいのが助かります。",
    "在宅勤務中に机の横で使用。運転音が気にならず、湿度が 50% 前後で安定するようになりました。",
]

# 実際の購入者の想定(比較用)
genuine = [
    "思ったより大きかった。加湿力は十分だけど、給水のたびに重いのが少し不満。",
    "3 週間使用。白い粉が出るので水道水ではなく浄水を使っています。性能は値段相応。",
]


def report(name: str, texts: list[str]) -> None:
    sets = [shingles(t) for t in texts]
    sims = [jaccard(a, b) for a, b in combinations(sets, 2)]
    print(f"{name:12s} ペア数={len(sims):2d} 最小={min(sims):.2f} 平均={sum(sims)/len(sims):.2f} 最大={max(sims):.2f}")


report("テンプレート", template)
report("言い換え", paraphrased)
report("言い換え+実購入", paraphrased + genuine)

THRESHOLD = 0.3
for name, texts in [("テンプレート", template), ("言い換え", paraphrased)]:
    sets = [shingles(t) for t in texts]
    hit = sum(jaccard(a, b) >= THRESHOLD for a, b in combinations(sets, 2))
    print(f"{name}: 類似度 {THRESHOLD} 以上のペア {hit}/{len(texts)*(len(texts)-1)//2}")
テンプレート       ペア数= 6 最小=0.25 平均=0.46 最大=0.61
言い換え         ペア数= 6 最小=0.00 平均=0.02 最大=0.06
言い換え+実購入     ペア数=15 最小=0.00 平均=0.02 最大=0.06
テンプレート: 類似度 0.3 以上のペア 4/6
言い換え: 類似度 0.3 以上のペア 0/6

テンプレート型は 6 組中 4 組が類似度 0.3 以上で束ねられましたが、書き分けた文面は最大でも 0.06 で、実際の購入者の文章と区別がつきません。文面の重複検知は安価なスパムを落とす最初のふるいとして残し、LLM を使う相手には別の手がかりが必要です。

手がかりは「文章」より「誰が・いつ・どこに」

LLM が下げたのは文章を作るコストです。偽アカウントを大量に作り、維持し、標的に向けて動かすコストは、文章ほどには下がっていません。そこで、文章の外にある次の手がかりを使います。

手がかり 例 偽装のしにくさ
アカウント同士のつながり 同じ商品群に集中してレビューする、端末・支払い手段・登録時の連絡先が重なる 高い。多数のアカウントを別々に用意する費用がかかる
時間の偏り 特定の商品に高評価が短時間に集中する、作成直後のアカウントが一斉に書く 中程度。分散させるほど水増しの効果が落ちる
体験の裏付け 購入・予約・来店の記録と投稿者が結び付いているか 高い。実際に購入すると費用がかかる
文面 テンプレートの使い回し 低い。LLM で書き分けられる

ポイントは、攻撃者が LLM で安くできる部分(文面)ではなく、お金や手間がかかる部分(アカウント・購入・時間)で見ることです。

アカウントのつながりを密なかたまりとして取り出す

偽レビューを依頼する側の目的は、特定の商品の評価を上げる(または競合の評価を下げる)ことです。そのため、偽アカウント群は「少数の標的商品に、多数のアカウントが集中してレビューする」という密な結び付きを作ります。これを、アカウント×商品の二部グラフから密な部分を取り出す問題として扱えます。

偽アカウントは、人気商品にもレビューを付けて普通のアカウントを装うことがあります。Hooi らの FRAUDAR(KDD 2016)は、この偽装に強い密度の定義として、レビュー数の多い商品への辺の重みを下げる方法を提案しています。以下はその考え方を簡略化した実装で、「辺の重みの合計 / ノード数」が最大になるまで、寄与の小さいノードを 1 つずつ外していきます。

模擬データは、実ユーザー 2,000 人(人気商品に偏ってレビュー)、商品 500 件、偽アカウント 40 件です。偽アカウントは標的の 10 商品のうち 7 つにレビューし、人気商品にも 3 つずつレビューして偽装します。

dense_block.py
"""アカウント×商品のレビューグラフから、密に結びついたかたまりを貪欲法で取り出す。

FRAUDAR (Hooi et al., KDD 2016) の考え方を簡略化したもの:
- 辺の重みを 1/log(商品の被レビュー数 + c) にして、人気商品へのレビュー(偽装)を軽く扱う
- 「重みの合計 / ノード数」が最大になるまで、寄与の小さいノードを 1 つずつ外す
データはすべて乱数で作った模擬データ。
"""
import heapq
import math
import random
from collections import defaultdict

random.seed(7)
N_USERS, N_PRODUCTS = 2000, 500
popular = list(range(20))            # 人気商品
targets = list(range(490, 500))       # 評価を水増ししたい商品
fraud = [f"f{i}" for i in range(40)]  # 偽アカウント

edges: set[tuple[str, int]] = set()
for u in range(N_USERS):
    k = random.randint(1, 8)
    for _ in range(k):
        # 実ユーザーは人気商品に偏る
        p = random.choice(popular) if random.random() < 0.4 else random.randrange(N_PRODUCTS - 10)
        edges.add((f"u{u}", p))
for f in fraud:
    for p in random.sample(targets, 7):          # 標的 10 商品のうち 7 つにレビュー
        edges.add((f, p))
    for p in random.sample(popular, 3):          # 人気商品にもレビューして普通のアカウントを装う
        edges.add((f, p))

deg_p = defaultdict(int)
for _, p in edges:
    deg_p[p] += 1
w = {e: 1 / math.log(deg_p[e[1]] + 5) for e in edges}

adj = defaultdict(dict)
for (u, p), x in w.items():
    adj[("U", u)][("P", p)] = x
    adj[("P", p)][("U", u)] = x


def peel(adj):
    alive = set(adj)
    contrib = {n: sum(adj[n].values()) for n in adj}
    total = sum(w.values())
    heap = [(c, n) for n, c in contrib.items()]
    heapq.heapify(heap)
    best, best_size, order = total / len(alive), len(alive), []
    while len(alive) > 1:
        c, n = heapq.heappop(heap)
        if n not in alive or abs(c - contrib[n]) > 1e-12:
            continue
        alive.remove(n)
        order.append(n)
        total -= contrib[n]
        for m, x in adj[n].items():
            if m in alive:
                contrib[m] -= x
                heapq.heappush(heap, (contrib[m], m))
        if total / len(alive) > best:
            best, best_size = total / len(alive), len(alive)
    removed = set(order[: len(adj) - best_size])
    return {n for n in adj if n not in removed}, best


block, score = peel(adj)
users = {n[1] for n in block if n[0] == "U"}
prods = {n[1] for n in block if n[0] == "P"}
tp = len(users & set(fraud))
print(f"辺の数={len(edges)} アカウント={N_USERS + len(fraud)} 商品={N_PRODUCTS}")
print(f"取り出したかたまり: アカウント {len(users)} 件, 商品 {len(prods)} 件, 密度 {score:.3f}")
print(f"そのうち偽アカウント {tp}/{len(fraud)}, 実ユーザー {len(users) - tp}")
print(f"標的商品 {len(prods & set(targets))}/{len(targets)}, 人気商品 {len(prods & set(popular))}")
辺の数=9271 アカウント=2040 商品=500
取り出したかたまり: アカウント 40 件, 商品 13 件, 密度 1.610
そのうち偽アカウント 40/40, 実ユーザー 0
標的商品 10/10, 人気商品 3

取り出したかたまりは偽アカウント 40 件すべてで、実ユーザーは含まれませんでした。乱数の種を 1〜3 に変えても同じく 40/40、実ユーザー 0 件でした。辺の重みを付けずに(すべて 1 で)同じ処理をすると、偽アカウントは同じく取り出せましたが、かたまりに含まれる人気商品が 3 件から 9 件に増えました。重み付けは、偽装のために触った人気商品をかたまりから外し、「どの商品が標的か」をはっきりさせる効果があります。

この模擬データは偽アカウントの動きが単純なので、実データではここまできれいには分かれません。実務では次のような工夫が必要です。

  • 二部グラフの相手を商品だけでなく、端末の識別子・支払い手段・登録時のメールアドレスのドメインや電話番号の帯などに広げる。偽アカウントの運営者はこれらを使い回しがちです
  • 一度に取り出すのは 1 つのかたまりなので、取り出した部分を除いて繰り返し、複数のかたまりを見つける
  • かたまりは「疑わしい集団の候補」で、即時の処分ではなく、人が確認する単位として扱う。購入記録の有無や、商品の出品者との関係を確認してから判断します

時間の偏りとアカウントの顔ぶれを組み合わせる

つながりの分析は一定量のデータがたまってから効く方法です。投稿の直後に使えるのは、時間の偏りです。ただし、セールやテレビでの紹介でもレビューは急増するので、急増だけで判定すると正当な投稿を止めてしまいます。そこで、急増と「誰が書いているか」を組み合わせます。

burst.py
"""商品ごとの高評価レビューの急増と、投稿者の顔ぶれ(作成直後のアカウントの割合)を組み合わせて判定する。

データは固定値の模擬データ。判定は削除ではなく「表示保留して人が確認する」候補を出す。
"""
from statistics import median

# 商品ごとの、過去 14 日の「星 5 レビュー数 / 日」と、直近 1 日の投稿者のアカウント作成からの日数
products = {
    "加湿器A(セール中)": {
        "daily5": [3, 4, 2, 5, 3, 4, 3, 2, 4, 3, 5, 4, 3, 19],
        "today_account_age_days": [400, 820, 35, 1200, 90, 15, 300, 610, 2, 77, 950, 140, 260, 33, 510, 45, 700, 1, 180],
    },
    "加湿器B": {
        "daily5": [1, 0, 2, 1, 1, 0, 1, 2, 1, 1, 0, 1, 1, 14],
        "today_account_age_days": [1, 2, 1, 3, 0, 1, 2, 1, 4, 2, 1, 0, 3, 2],
    },
    "加湿器C": {
        "daily5": [2, 3, 2, 2, 3, 1, 2, 3, 2, 2, 3, 2, 2, 3],
        "today_account_age_days": [5, 600, 1],
    },
}


def robust_z(history: list[int], today: int) -> float:
    m = median(history)
    mad = median(abs(x - m) for x in history) or 1.0
    return (today - m) / (1.4826 * mad)


for name, d in products.items():
    *hist, today = d["daily5"]
    z = robust_z(hist, today)
    ages = d["today_account_age_days"]
    new_ratio = sum(a <= 7 for a in ages) / len(ages)
    burst = z >= 5
    fresh = new_ratio >= 0.5 and len(ages) >= 5
    action = "表示保留・人が確認" if burst and fresh else ("監視を強める" if burst or fresh else "なし")
    print(f"{name:14s} 今日={today:2d} z={z:5.1f} 作成7日以内={new_ratio:4.0%} -> {action}")
加湿器A(セール中)     今日=19 z= 10.8 作成7日以内= 11% -> 監視を強める
加湿器B           今日=14 z=  8.8 作成7日以内=100% -> 表示保留・人が確認
加湿器C           今日= 3 z=  0.7 作成7日以内= 67% -> なし

加湿器 A は急増していますが投稿者の多くは古くからのアカウントなので、表示は止めずに監視を強めるだけにします。加湿器 B は急増していて、投稿者がほぼ全員作成直後のアカウントなので、表示を保留して人が確認します。加湿器 C は作成直後のアカウントの割合は高いものの件数が少なく、急増もしていないので対象外です。中央値と MAD(中央値からの絶対偏差の中央値)を使うのは、過去にもセールなどで外れ値があった場合に平均と標準偏差よりしきい値がぶれにくいからです。

対処は削除だけにしない

偽の投稿を見つけたあとの対処は、確からしさに応じて段階を分けます。

  • 表示の保留:疑わしい投稿は公開前に保留し、確認後に公開する。Google や Amazon が「大半を掲載前に止めた」としているのは、この位置で処理しているためです
  • 評価への重みを下げる:確証がない投稿は削除せず、星の平均やランキングへの寄与を小さくする。投稿者本人の画面では見えたままにしておくと、偽アカウントの運営者は効いていないことに気付きにくくなります(その場合でも、規約やヘルプでこうした扱いがあることは明示しておきます)
  • 体験の裏付けを表示する:購入済み・予約済みなどの印を付け、裏付けのある投稿を優先して表示する
  • ネットワーク単位での処分:つながりの分析で同じ運営者と判断したアカウント群は、1 件ずつではなくまとめて停止する。1 件ずつ止めると、残ったアカウントで運用を続けられます
  • 投稿後の見直し:かたまりは後からわかることが多いので、過去の投稿も定期的に再評価し、さかのぼって外す。Google も投稿から時間が経ったレビューを見直す運用に触れています

どの段階でも、誤判定された正当な利用者が異議を申し立てられる窓口を用意します。偽装を見抜く仕組みは必ず誤検知を出すので、救済の仕組みがないと、利用者の信頼を守るための対策が逆に信頼を損ないます。

運用で決めておくこと

まず、自社の規約で何を違反とするかを決めます。「AI を使って書いた投稿」ではなく、「体験していない評価」「1 人が複数アカウントで書いた評価」「対価の提供を隠した評価」「事業者による自作自演」といった、FTC 規則やステルスマーケティング規制が問題にしている行為を基準にすると、判定と説明の軸がぶれません。

次に、判定に使うデータを残しておきます。アカウントの作成日時、投稿時の端末やネットワークの情報、購入記録との対応がないと、つながりや時間の分析ができません。個人情報の扱いとの兼ね合いがあるので、保存する項目と期間は法務と合わせて決めます。

最後に、平常時の値を測っておきます。商品カテゴリごとの 1 日あたりのレビュー数、作成直後のアカウントの割合、セール時の伸び方がわからないと、しきい値を決められません。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?