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
Download code/scripts/build_text_corpus.py from x6nux/laya-ad: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/x6nux/laya-ad/resolve/main/code/scripts/build_text_corpus.py
- Command line
-
hf download hf://x6nux/laya-ad/code/scripts/build_text_corpus.py
-
curl -L -o build_text_corpus.py https://huggingface.co/x6nux/laya-ad/resolve/main/code/scripts/build_text_corpus.py
10.6 kB
| #!/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()) | |