Text Classification
Transformers
Safetensors
Chinese
spam-detection
advertising-detection
ad-classification
chinese
modernbert
mmbert
multi-task
four-head
Instructions to use x6nux/laya-ad with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use x6nux/laya-ad with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="x6nux/laya-ad")# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("x6nux/laya-ad", device_map="auto") - Notebooks
- Google Colab
- Kaggle
File size: 10,582 Bytes
71dabc7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | #!/usr/bin/env python3
"""把清洗后的两源语料整理成可直接微调的文本语料。
原始清洗(build_archive_dataset / build_channel_dataset)解决的是"记录级"问题:
坏行、重复消息、冲突判定、发送者级伪标签。这里解决"语料级"问题,缺一个都会毁掉微调:
1. 标签口径统一 —— 存档用 clean,现有语料用 normal,不统一会把同一类拆成两个类。
2. 与现有训练集去重 —— 实测 23.66% 的群聊文本与 data/processed/train|val 完全重复,
不排除就会让新 val 直接读到训练过的样本。
3. 模板近似去重 —— 广告文本大量是同一模板换 @用户名/链接/数字,
归一化后仍有 22.9% 冗余,会放大少数模板的权重并跨集泄漏。
4. 分源时间切分 —— 两源时间分布不同,全局切分会让某个源整体落进 train 或 val。
"""
from __future__ import annotations
import argparse
import collections
import datetime as dt
import hashlib
import json
import os
import re
import sys
import unicodedata
LABEL_MAP = {"ad": "ad", "normal": "normal", "clean": "normal"}
def norm_key(text: str) -> str:
"""归一化去重键:抹掉链接/用户名/数字,让同模板的变体落到一起。"""
t = unicodedata.normalize("NFKC", text).lower()
t = re.sub(r"https?://\S+|t\.me/\S+|www\.\S+", "<url>", t)
t = re.sub(r"@[a-z0-9_]{3,}", "<at>", t)
t = re.sub(r"\d+", "<n>", t)
t = re.sub(r"\s+", " ", t).strip()
return t
def h(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()
def load_jsonl(path: str) -> list[dict]:
if not os.path.exists(path):
print(f"[skip] 缺 {path}", file=sys.stderr)
return []
with open(path, encoding="utf-8") as fh:
return [json.loads(line) for line in fh if line.strip()]
def load_existing(paths: list[str]) -> tuple[set[str], set[str]]:
exact, normed = set(), set()
for p in paths:
for r in load_jsonl(p):
t = (r.get("text") or "").strip()
if t:
exact.add(h(t))
normed.add(norm_key(t))
return exact, normed
def collect(args) -> tuple[list[dict], dict]:
rows: list[dict] = []
stats = collections.Counter()
group = load_jsonl("data/processed/archive/messages_trusted.jsonl")
for r in group:
if r.get("status") == "pseudo" or r.get("label_source") == "laya_pseudo":
continue
rows.append({**r, "source": "group"})
stats["group_raw"] += 1
if args.include_pseudo:
for r in load_jsonl("data/processed/archive/messages_pseudo.jsonl"):
rows.append({**r, "source": "group_pseudo"})
stats["pseudo_raw"] += 1
channel = load_jsonl("data/processed/channel/messages.jsonl")
for r in channel:
rows.append({**r, "source": "channel"})
stats["channel_raw"] += 1
# TGCN 群导出:标签是本地模型伪标签,只有高置信 normal 才收进训练集。
# 伪标签 ad 不自动进(自己训练自己只会放大现有偏差),进 needs_review 通道。
if not args.no_tgcn:
tgcn = load_jsonl("data/processed/chatexport_2026-10-27/messages.jsonl")
stats["tgcn_raw"] = len(tgcn)
for r in tgcn:
p = r.get("p_ad")
if r.get("label") == "normal" and p is not None and p <= args.tgcn_normal_max:
rows.append({**r, "source": "tgcn", "ad_kind": None})
stats["tgcn_kept"] += 1
elif r.get("label") == "ad":
stats["tgcn_ad_to_review"] += 1
out = []
for r in rows:
text = (r.get("text") or "").strip()
label = LABEL_MAP.get(r.get("label"))
if not text or label is None:
stats["drop_empty_or_unlabeled"] += 1
continue
if r.get("needs_review"):
stats["drop_needs_review"] += 1
continue
if args.drop_low_info and r.get("low_info"):
stats["drop_low_info"] += 1
continue
out.append({"text": text, "label": label, "source": r["source"],
"chat_id": r.get("chat_id"), "message_id": r.get("message_id"),
"date": int(r.get("date") or 0),
"ad_kind": r.get("ad_kind"), "label_trust": r.get("label_trust", "prior"),
"norm": norm_key(text)})
return out, stats
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="data/processed/text_corpus")
ap.add_argument("--val-ratio", type=float, default=0.1)
ap.add_argument("--neg-multiplier", type=float, default=3.0,
help="平衡版本里负样本上限 = 正样本 × 该系数")
ap.add_argument("--include-pseudo", action="store_true", help="把 Laya 补标样本也算进来")
ap.add_argument("--no-tgcn", action="store_true", help="不并入 TGCN 群导出的高置信 normal")
ap.add_argument("--tgcn-normal-max", type=float, default=0.1,
help="TGCN 伪标签 normal 的收录阈值(p_ad ≤ 该值)")
ap.add_argument("--drop-low-info", action="store_true", help="丢掉 <4 字/签到类短文本")
ap.add_argument("--exclude", nargs="*",
default=["data/processed/train.jsonl", "data/processed/val.jsonl"],
help="现有语料,用于排除重叠样本")
args = ap.parse_args()
rows, stats = collect(args)
# 1) 排除与现有语料的重叠(近似也算)
ex_exact, ex_norm = load_existing(args.exclude)
kept = []
for r in rows:
if h(r["text"]) in ex_exact or r["norm"] in ex_norm:
stats["drop_existing_overlap"] += 1
continue
kept.append(r)
# 2) 归一化去重:同模板只留最长的一条;标签冲突的整组丢弃
groups: dict[str, list[dict]] = collections.defaultdict(list)
for r in kept:
groups[r["norm"]].append(r)
deduped, conflict_groups = [], 0
for key, grp in groups.items():
labels = {x["label"] for x in grp}
if len(labels) > 1:
conflict_groups += 1
stats["drop_norm_conflict"] += len(grp)
continue
best = max(grp, key=lambda x: (len(x["text"]), -x["date"]))
best["dup_group"] = len(grp)
stats["merged_duplicates"] += len(grp) - 1
deduped.append(best)
# 3) 分源时间切分,再按归一化键对齐,避免同模板跨集
for source in {r["source"] for r in deduped}:
sub = sorted((r for r in deduped if r["source"] == source), key=lambda r: r["date"])
if not sub:
continue
cutoff = sub[int(len(sub) * (1 - args.val_ratio))]["date"]
for r in sub:
r["split"] = "val" if r["date"] >= cutoff else "train"
anchor: dict[str, str] = {}
for r in sorted(deduped, key=lambda r: r["date"]):
r["split"] = anchor.setdefault(r["norm"], r["split"])
train = [r for r in deduped if r["split"] == "train"]
val = [r for r in deduped if r["split"] == "val"]
# 4) 平衡版本:压负样本,保留全部正样本
pos = sum(1 for r in train if r["label"] == "ad")
neg_cap = int(pos * args.neg_multiplier)
negs = [r for r in train if r["label"] == "normal"]
# 按时间等距抽样,避免只留早期样本
if len(negs) > neg_cap:
step = len(negs) / neg_cap
negs = [negs[int(i * step)] for i in range(neg_cap)]
balanced = [r for r in train if r["label"] == "ad"] + negs
balanced.sort(key=lambda r: r["date"])
out = args.out
os.makedirs(out, exist_ok=True)
fields = ("text", "label", "source", "chat_id", "message_id", "date", "ad_kind",
"label_trust", "dup_group")
def write(name: str, data: list[dict], only_text_label: bool) -> int:
with open(os.path.join(out, name), "w", encoding="utf-8") as fh:
for r in data:
rec = {"text": r["text"], "label": r["label"]} if only_text_label else \
{k: r.get(k) for k in fields if k in r}
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
return len(data)
n_tr = write("train.jsonl", train, True)
n_va = write("val.jsonl", val, True)
n_ba = write("train_balanced.jsonl", balanced, True)
write("manifest.jsonl", train + val, False)
with open(os.path.join(out, "label_map.json"), "w", encoding="utf-8") as fh:
json.dump({name: i for i, name in enumerate(sorted({"ad", "normal"}))},
fh, ensure_ascii=False, indent=2)
def dist(data: list[dict]) -> dict:
return dict(collections.Counter(r["label"] for r in data))
def by_source(data: list[dict]) -> dict:
c = collections.Counter((r["source"], r["label"]) for r in data)
return {f"{s}/{l}": v for (s, l), v in sorted(c.items())}
report = {
"input_stats": dict(stats),
"norm_conflict_groups": conflict_groups,
"deduped": len(deduped),
"train": n_tr, "val": n_va, "train_balanced": n_ba,
"train_dist": dist(train), "val_dist": dist(val), "balanced_dist": dist(balanced),
"train_by_source": by_source(train), "val_by_source": by_source(val),
"date_range": [dt.datetime.fromtimestamp(min(r["date"] for r in deduped)).isoformat(timespec="seconds"),
dt.datetime.fromtimestamp(max(r["date"] for r in deduped)).isoformat(timespec="seconds")],
"out": os.path.abspath(out),
}
with open(os.path.join(out, "report.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
md = ["# 文本语料构建报告", "",
f"- 归一化去重后 **{len(deduped)}** 条(丢弃/合并明细见 `input_stats`)",
f"- 模板冲突组(同模板不同标签,整组丢弃):{conflict_groups}", "",
"| 集合 | 条数 | 标签分布 |", "|---|---|---|",
f"| train | {n_tr} | `{dist(train)}` |",
f"| val | {n_va} | `{dist(val)}` |",
f"| train_balanced | {n_ba} | `{dist(balanced)}` |", "",
f"- train 来源构成:`{by_source(train)}`",
f"- val 来源构成:`{by_source(val)}`", "",
"## 输入侧丢弃统计", "", f"`{dict(stats)}`"]
with open(os.path.join(out, "report.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(md) + "\n")
print("\n".join(md))
print(f"[out] {os.path.abspath(out)}")
if __name__ == "__main__":
sys.exit(main())
|