跳轉到

10.2 抄襲偵測與指紋比對#

概念說明#

抄襲偵測(學術論文、程式碼查重、新聞洗稿偵測)的本質問題:在海量文件集中找出「有大段雷同文字」的文件對。逐對計算編輯距離(4.1 節 O(nm))在百萬文件上完全不可行——10⁶ 份文件的兩兩組合是 5×10¹¹ 對。解法是指紋比對(fingerprinting):先把每份文件化約成小的「指紋集合」,只比較指紋,用雜湊與倒排把問題從「兩兩比對」降為「查表」。

Shingling:把文字切成連續 n-gram(word shingle,如 5 詞一片),文件 = shingle 集合。兩文件的相似度用 Jaccard 係數:|A∩B|/|A∪B|——1 表示完全相同、0 表示完全不同。shingle 長度 n 是精確率/召回率的旋鈕:n 大 → 只抓整段照抄;n 小 → 連改寫也抓到但誤判多。

指紋壓縮:shingle 本身占空間,用雜湊(Rabin 指紋,2.1 節 Rabin-Karp 的滾動雜湊正是為此設計——O(1) 更新滑動視窗雜湊)把每個 shingle 映射成 64-bit 整數。文件指紋集 = {hash(shingle)}。

MinHash:要快速估 Jaccard 而不必真的算交集聯集——用 k 個不同的雜湊函數,每個函數取「集合指紋的最小值」,則兩集合最小值相等的機率 ≈ Jaccard 係數。k=100 時估計誤差約 5%,而每文件只需存 100 個整數。進一步的 LSH(局部敏感雜湊)把 MinHash 簽名分帶(b 帶 × r 列),只要有一帶全同就當候選對——把「找相似對」化為雜湊桶查詢,支援百萬級文件的近線性擴展。這也是 7.3 節詞向量最近鄰搜尋、以及推薦系統協同過濾的同一套思想家族。

比對策略分兩級:

  1. 粗篩(cheap、全庫掃):shingle 指紋倒排或 MinHash+LSH,找出「指紋重疊率高的候選對」。
  2. 精查(expensive、僅候選對):對候選對跑 4.2 節最長共同子字串或 4.1 節 diff,輸出具體雷同片段與位置——這才是使用者要的「哪裡抄了」。

程式碼查重的特殊性:變數改名、語句重排會騙過文字指紋,故先做詞法規格化(token 化、變數名統一成 v1/v2、去註解空白)再指紋化;更深的做法(AST 指紋、PDG 比對)已屬編譯器技術範疇。

演算法步驟#

  1. 規格化:分詞(7.1/7.2)、小寫、去停用詞(可選)、程式碼另做 token 規格化。
  2. 指紋化:滑動視窗產生 n-gram shingle → Rabin 滾動雜湊 → 64-bit 指紋集合。
  3. 粗篩:把指紋放進全域倒排(指紋 → 文件集);或為每文件建 MinHash 簽名+LSH 分帶;統計每對候選的共享指紋數。
  4. 計分:Jaccard ≈ 共享數 / (|A|+|B|−共享數);超過門檻者為可疑對。
  5. 精查:對可疑對找最長共同子字串(4.2 節)或以共同指紋定位連續雷同區段,輸出片段與占比。

Python 程式碼範例#

import re
from collections import defaultdict

def toks_of(text):
    """中文切成單字、英文切成詞(無分詞器的通用做法)"""
    toks = []
    for run in re.findall(r"[\u4e00-\u9fff]+|[A-Za-z0-9]+", text.lower()):
        toks.extend(run if "\u4e00" <= run[0] <= "\u9fff" else [run])
    return toks

def shingles(text, k=5):
    toks = toks_of(text)
    return {" ".join(toks[i:i + k]) for i in range(len(toks) - k + 1)}

def jaccard(a, b):
    return len(a & b) / len(a | b) if a | b else 0.0

FINGER = defaultdict(list)          # 指紋倒排:hash -> 文件
DOC_FP = {}                         # 文件 -> 指紋集

def add_doc(doc_id, text):
    fps = {hash(s) for s in shingles(text)}
    DOC_FP[doc_id] = fps
    for f in fps:
        FINGER[f].append(doc_id)

def candidates(threshold=0.1):
    pair_count = defaultdict(int)   # 粗篩:只數共同指紋
    for f, docs in FINGER.items():
        if len(docs) > 20:          # 超高頻指紋(樣板句)略過
            continue
        for i in range(len(docs)):
            for j in range(i + 1, len(docs)):
                pair_count[tuple(sorted((docs[i], docs[j])))] += 1
    out = []
    for (d1, d2), c in pair_count.items():
        j = c / (len(DOC_FP[d1]) + len(DOC_FP[d2]) - c)
        if j >= threshold:
            out.append((d1, d2, round(j, 3)))
    return sorted(out, key=lambda x: -x[2])

docs = {
    "論文A": "本研究提出一個新的演算法用於處理大規模文字資料,實驗結果顯示方法有效。",
    "論文B": "本研究提出一個新的演算法用於處理大規模文字資料,我們的實驗證明性能優異。",
    "論文C": "深度學習在自然語言處理的應用已經非常廣泛,改變了許多傳統方法。",
}
for d, t in docs.items():
    add_doc(d, t)
print("可疑對(Jaccard):", candidates(0.2))

# 精查:對候選對找雷同片段(直接用共同 shingle 合併)
def common_spans(d1, d2, k=5):
    toks1, toks2 = toks_of(docs[d1]), toks_of(docs[d2])
    s2 = {" ".join(toks2[i:i + k]) for i in range(len(toks2) - k + 1)}
    spans, cur = [], []
    for i in range(len(toks1) - k + 1):
        sh = " ".join(toks1[i:i + k])
        if sh in s2:
            cur.append(i)
        elif cur:
            spans.append((cur[0], cur[-1] + k)); cur = []
    if cur: spans.append((cur[0], cur[-1] + k))
    return ["".join(toks1[a:b]) for a, b in spans]

print("A 與 B 的雷同片段:", common_spans("論文A", "論文B"))

複雜度分析#

  • 指紋化:每文件 O(長度);滾動雜湊使 n-gram 雜湊均攤 O(1) 每片。
  • 粗篩(倒排版):O(Σ 文件指紋數),但高頻指紋桶會退化(樣板句/常見片語)→ 需頻率截斷或 IDF 加權(8.2 節思想再現);LSH 版查詢近 O(簽名長)。
  • 精查:每候選對 O(nm) 或後綴陣列 O(n log n)——只對少數候選執行,總成本可控。
  • 量級感受:10⁵ 文件 × 10³ 指紋 → 粗篩秒級;若不用指紋而兩兩跑編輯距離,需月級。指紋 = 「把平方問題壓成近線性」的範式,與 2.1 節 Rabin-Karp「把多樣式比對壓成雜湊查表」同源。

練習#

  1. 實作 MinHash:k=50 個雜湊函數,對兩份文件的 shingle 集算簽名,驗證「簽名相等比例 ≈ Jaccard」;再把 k 調到 10、200,畫誤差曲線。
  2. 實作 LSH 分帶(b=20 帶、r=5 列):把 100 維 MinHash 簽名分帶雜湊,統計候選對的召回率與篩掉比例,說明 b、r 如何控制「相似度門檻」曲線。
  3. 把兩段文字做改寫攻擊(換同義詞、調整語序、插插入語)後再跑系統,觀察 Jaccard 下降幅度;思考 shingle 長度 k 與「字級 vs 詞級」的選擇如何影響抗改寫能力,寫 200 字的設計取捨。