My avatar

北雁云依的博客

My avatar

北雁云依的博客

决不跪在地上
以显出刽子手们的高大
好阻挡自由的风

RSS
· 浏览

用 NLP 模型过滤缠扰言论

截至目前,王旷逸缠扰(stalking)我已一年有余,但我始终不愿意放弃博客评论区。为此,我决定用 NLP 模型过滤掉他的言论,以保护自己与读者的心理健康。

然而,王某某却从未停止对博主的 stalking 行为,博客评论区也一次次被搞得不堪入目。隐藏王某某的评论是出于对观众情绪的保护,而不是对博主的。恰恰相反,为了保护观众,博主需要巡查评论区,被强制阅读骚扰言论。关闭评论区才是为了保护博主。然而,这却会牺牲其他用户的权利。 ——2024.12.27,本博客告示

我始终认为,匿名性是互联网不可或缺的一部分。因此,在评论系统默认会记录 IP 及 UA 的情况下,我仍然主动关闭了其记录 IP 与 UA 的功能,并且不会要求用户在评论前先注册、登录。这就为王旷逸在评论区的持续缠扰提供了可能性。

缠扰(英语:stalking)又称纠缠、跟踪骚扰、或死缠烂打,是指一个人或团体对另一个人给予过多的关注,而造成被关注者的困扰和恐惧。

……

但在中国大陆现行司法体制下,依旧缺乏对个人面临非法跟踪、骚扰、监视等缠扰行为时的司法保护。即使是公众人物,面对私生饭缠扰时,(也)可能长期无法解决。 ——缠扰,维基百科

尽管我不会放弃将王旷逸绳之以法的努力,但也许评论区的重新开放不需要等待司法机构对王旷逸的裁决。只需要根据王旷逸的语言特征,就可以对其言论进行过滤,从而既不让读者因为阅读到他的言论而感到不适,又不会让我为了审查评论而强制观看,还不会因为关闭评论区而牺牲其他用户的权利。

过去一年间,王旷逸在评论区已经发布了 130 条,共计一万余字的评论,而评论总数也来到了 434 条。这已经足够训练防骚扰模型了。于是,在 ChatGPT(GPT-4o)的帮助下,我进行了如下实践:

数据导出

Waline 提供了导出功能,一键即可将数据库导出为 json。不过即使没有,我也可以读写它的 SQLite 数据库。进行简易处理后,用 polars 读取,过滤掉部分评论含有的 HTML 标签(缓解过拟合)即可:

import polars as pl

df = pl.read_json("dataset.json").select(
    pl.col("comment").str.replace_all(r"<[^>]*>", ""), pl.col("label").cast(pl.UInt8)
)

基于 TF-IDF 分析的传统方法

简单地说,TF-IDF 是一类基于词频的统计方法。通过 TF-IDF 的处理,我可以得到不同文本中的词频信息,并进行相应的分类。此后,只需对新评论进行回归分析,即可判定其是否来自王旷逸。

import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.metrics import accuracy_score, classification_report

# 3. 训练 / 测试集划分
X_train, X_test, y_train, y_test = train_test_split(
    df["comment"], df["label"], test_size=0.2, random_state=42
)

# 4. TF-IDF 向量化
vectorizer = TfidfVectorizer()  # 只保留 5000 个最常见的词
X_train_tfidf = vectorizer.fit_transform(X_train)
X_test_tfidf = vectorizer.transform(X_test)

# 5. 训练逻辑回归分类器
# model = LogisticRegression()
model = SVC(class_weight="balanced")
model.fit(X_train_tfidf, y_train)

# 6. 评估模型
y_pred = model.predict(X_test_tfidf)
print("准确率:", accuracy_score(y_test, y_pred))
print("分类报告:\n", classification_report(y_test, y_pred))

# 7. 预测新评论
new_comments = [
    "我觉得这个服务有点问题。",
]
new_comments_tfidf = vectorizer.transform(new_comments)
predictions = model.predict(new_comments_tfidf)
print("预测结果:", predictions)  # 1 表示该用户的风格,0 表示不是

遗憾的是,在现有数据中,TF-IDF 方法只能做到 80% 的准确率。这是因为 TF-IDF 只基于词频,却忽略了词语的先后组合关系,从而不能更准确地提取王旷逸言论的特征。为了做到这一点,我们需要引入基于神经网络的机器学习方法。也许 RNN 就足以进行分类,但我就是想耍耍最新最热的 Transformer 方法。

基于 BERT 的神经网络方法

我曾试图使用 LLM API 过滤王旷逸的言论,会触发 LLM 的风控,造成账号被限制。也许是因为王旷逸的言论实在不堪入目吧。因此,我需要找到一个较小的模型,以满足在服务器进行部署的需求。

BERT 就是一个这样的模型。它可以使用 GPU 或 CPU 进行训练,训练时显存 / 内存占用大约 4G。如果使用 CPU 部署,内存占用可以控制到 1G 甚至 600M(qint8 量化)以内。

训练

而由于 BERT 的出色性能,我还可以在本地对其进行进一步微调训练(Fine-Tuning),以提升它对王旷逸言论的分类能力。

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from transformers import BertTokenizer, BertForSequenceClassification
from sklearn.model_selection import train_test_split


tokenizer = BertTokenizer.from_pretrained("bert-base-chinese")


# 数据集定义
class CommentDataset(Dataset):
    def __init__(self, comments, labels, tokenizer, max_len=128):
        self.comments = comments
        self.labels = labels
        self.tokenizer = tokenizer
        self.max_len = max_len

    def __len__(self):
        return len(self.comments)

    def __getitem__(self, idx):
        encoding = self.tokenizer(
            self.comments[idx],
            truncation=True,
            padding="max_length",
            max_length=self.max_len,
            return_tensors="pt",
        )
        return {
            "input_ids": encoding["input_ids"].squeeze(0),
            "attention_mask": encoding["attention_mask"].squeeze(0),
            "label": torch.tensor(self.labels[idx], dtype=torch.uint8),
        }


# 划分数据集
train_texts, val_texts, train_labels, val_labels = train_test_split(
    df["comment"].to_list(), df["label"].to_list(), test_size=0.2, random_state=42
)

train_dataset = CommentDataset(train_texts, train_labels, tokenizer)
val_dataset = CommentDataset(val_texts, val_labels, tokenizer)

train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=16, shuffle=False)

# 加载模型
model = BertForSequenceClassification.from_pretrained("bert-base-chinese", num_labels=2)
# model=BertForSequenceClassification.from_pretrained("./fine_tuned_bert")
device = torch.device(
    "cuda"
    if torch.cuda.is_available()
    else "mps" if torch.mps.is_available() else "cpu"
)
model.to(device)

# 训练设置
optimizer = optim.AdamW(model.parameters(), lr=2e-5)
criterion = nn.CrossEntropyLoss()


def train_epoch(model, train_loader, optimizer, criterion):
    model.train()
    total_loss, correct = 0, 0
    for batch in train_loader:
        input_ids, attention_mask, labels = (
            batch["input_ids"].to(device),
            batch["attention_mask"].to(device),
            batch["label"].to(device),
        )
        optimizer.zero_grad()
        outputs = model(input_ids, attention_mask=attention_mask)
        loss = criterion(outputs.logits, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
        correct += (outputs.logits.argmax(dim=1) == labels).sum().item()
    return total_loss / len(train_loader), correct / len(train_dataset)


def evaluate(model, val_loader, criterion):
    model.eval()
    total_loss, correct = 0, 0
    with torch.no_grad():
        for batch in val_loader:
            input_ids, attention_mask, labels = (
                batch["input_ids"].to(device),
                batch["attention_mask"].to(device),
                batch["label"].to(device),
            )
            outputs = model(input_ids, attention_mask=attention_mask)
            loss = criterion(outputs.logits, labels)
            total_loss += loss.item()
            correct += (outputs.logits.argmax(dim=1) == labels).sum().item()
    return total_loss / len(val_loader), correct / len(val_dataset)


# 训练循环
epochs = 2
for epoch in range(epochs):
    train_loss, train_acc = train_epoch(model, train_loader, optimizer, criterion)
    val_loss, val_acc = evaluate(model, val_loader, criterion)
    print(
        f"Epoch {epoch+1}/{epochs}: Train Loss={train_loss:.4f}, Train Acc={train_acc:.4f}, Val Loss={val_loss:.4f}, Val Acc={val_acc:.4f}"
    )

# 保存模型
model.save_pretrained("./fine_tuned_bert")
tokenizer.save_pretrained("./fine_tuned_bert")

通过观察输出可知,对各评论的识别准确率即超过 90%,最高达到了 95%。而从第三个 epoch 开始,模型在验证集上的准确率就出现了下降,这说明模型已经开始过拟合。因此,我选择了在第二个 epoch 后停止训练。

量化

为了部署,可以使用 onnx 对模型进行量化:

import torch
import onnx
from transformers import BertTokenizer, BertForSequenceClassification
from onnxruntime.quantization import quantize_dynamic, preprocess, QuantType

device = torch.device("cpu")

model_path = "./fine_tuned_bert"

# 加载模型
model = BertForSequenceClassification.from_pretrained(model_path)
tokenizer = BertTokenizer.from_pretrained(model_path)
model.eval()

# 导出 ONNX
dummy_input_ids = torch.randint(0, 20000, (1, 128)).to(device).to(torch.int64)
dummy_attention_mask = torch.ones((1, 128)).to(device).to(torch.int64)

torch.onnx.export(
    model,
    (dummy_input_ids, dummy_attention_mask),
    "bert_model.onnx",
    input_names=["input_ids", "attention_mask"],
    output_names=["logits"],
    dynamic_axes={"input_ids": {0: "batch_size"}, "attention_mask": {0: "batch_size"}},
    opset_version=20,
)

print("ONNX 模型导出成功!")

# 加载 ONNX 模型
onnx_model = onnx.load("bert_model.onnx")

preprocess.quant_pre_process(
    input_model=onnx_model, output_model_path="bert_model_preprocess.onnx"
)

quantize_dynamic(
    model_input="bert_model_preprocess.onnx",
    model_output="bert_model_quantized.onnx",
    weight_type=QuantType.QInt8,
)

print("ONNX 量化完成,模型已保存!")

并使用如下代码对量化前后的模型进行对比:

import onnxruntime as ort
import numpy as np


def evaluate(model_file: str, val_texts: list[str], val_labels: list[str]):
    session = ort.InferenceSession(
        model_file,
        providers=["CPUExecutionProvider"],  # CPU 部署
        sess_options=ort.SessionOptions(),
    )
    # 启用内存优化(减少 GPU/CPU 内存占用)
    session.set_providers(
        ["CPUExecutionProvider"],
        provider_options=[{"arena_extend_strategy": "kSameAsRequested"}],
    )
    correct = 0
    wrong = []

    for i in range(len(val_texts)):
        encoding = tokenizer(
            val_texts[i],
            padding="max_length",
            max_length=128,
            truncation=True,
            return_tensors="np",
        )
        input_ids, attention_mask = encoding["input_ids"], encoding["attention_mask"]
        outputs = session.run(
            ["logits"], {"input_ids": input_ids, "attention_mask": attention_mask}
        )
        output = int(np.argmax(outputs[0], axis=1)[0])
        if output == val_labels[i]:
            correct += 1
        else:
            wrong.append(val_texts[i])
        # correct += (outputs.logits.argmax(dim=1) == val_labels[i]).sum().item()
    return correct / len(val_dataset), wrong


original, quantized = evaluate("bert_model.onnx", val_texts, val_labels), evaluate(
    "bert_model_quantized.onnx", val_texts, val_labels
)
original, quantized

输出为 (0.9195402298850575, 0.9195402298850575),这意味着量化后的准确率并没有下降。

部署

这样以后,就可以拿 FastAPI 简单写一个服务端并用于部署了:

from fastapi import FastAPI
import numpy as np
from transformers import BertTokenizer
from pydantic import BaseModel
import onnxruntime as ort

session = ort.InferenceSession(
    "bert_model_quantized.onnx",
    providers=["CPUExecutionProvider"],  # CPU 部署
    sess_options=ort.SessionOptions(),
)

# 启用内存优化(减少 GPU/CPU 内存占用)
session.set_providers(
    ["CPUExecutionProvider"],
    provider_options=[{"arena_extend_strategy": "kSameAsRequested"}],
)


class Item(BaseModel):
    content: str


app = FastAPI()

# 加载模型
tokenizer: BertTokenizer = BertTokenizer.from_pretrained("./fine_tuned_bert")


@app.post("/predict")
def predict(comment: Item):
    encoding = tokenizer(
        comment.content,
        padding="max_length",
        max_length=128,
        truncation=True,
        return_tensors="np",
    )
    input_ids, attention_mask = encoding["input_ids"], encoding["attention_mask"]

    outputs = session.run(
        ["logits"], {"input_ids": input_ids, "attention_mask": attention_mask}
    )
    prediction = int(np.argmax(outputs[0], axis=1)[0])

    return {"predicted_label": prediction}

Waline 服务端有提供相关的接口,但文档不够清晰,总之稍作修改即可使用。

// index.js
import 'dotenv/config';
import { createRequire } from 'node:module';
import path from 'node:path';
import Application from 'thinkjs';

const require = createRequire(import.meta.url);

const ROOT_PATH = path.resolve(require.resolve('@waline/vercel'), '..');

const instance = new Application({
  ROOT_PATH,
  APP_PATH: path.resolve(ROOT_PATH, 'src'),
  proxy: true, // use proxy
  env: 'production',
});

instance.run();

let config = {};

try {
  const { default: conf } = await import('./config.js');
  config = conf;
} catch {
  // do nothing
}
for (const k in config) {
  think.config(k, config[k]);
}
// config.js
const config = {
  preSave: async (comment) => {
    /** @type {{ predicted_label: 0 | 1 }} */
    const res = await fetch('http://localhost:8000/predict', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: comment.comment }),
    }).then((res) => res.json());
    if (res.predicted_label === 1) {
      think.logger.warn('Stalking detected');
      comment.status = 'waiting';
    }
  },
};

export default config;

此后,复制其它平台上王旷逸与他人的言论进行测试,发现模型也能明确地鉴别。这更让他假装路人的行径显得可笑至极。

然而惯犯如此,却连一只飞猴(flying monkeys)都养不起,只能一再注册小号来“说句公道话”,这在很大程度上说明了王旷逸的无能:他不是 NPD,而只能通过学习社会工程学来邯郸学步;他没有 NPD 的社交关系,而只能自己开小号来凑数。这种拙劣模仿自然错漏百出,而且总会有“真情流露”的时刻。 ——《告王旷逸(Wang Xueqian)》,本博客

在 ChatGPT 的帮助下,以上过程共花费 5 人·小时,甚至低于王旷逸编写他过万字的缠扰言论的时间,显示了 LLM 辅助编程的价值。此后,我更可以不费力地更新模型,从而在王旷逸公开道歉、赔偿损失或遭受物理伤害、因故死亡以前,实现评论区对他的持续性屏蔽。

训练用语料与 python 代码均已在 GitHub 开源

分享

复制此页面地址到邦联宇宙搜索框以在邦联宇宙分享本文:https://blog.yunyi.beiyan.us/posts/FilterStalking

在本站发言,即意味着你同意评论政策