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())