重複資料清理不是先挑出相同資料,再把多餘的列移除。真正要回答的是:把多次出現改成只出現一次,下游的答案還相同嗎? 若沒有定義資料身分、使用範圍與消費端契約,即使程式執行成功,也不能證明結果保持正確。

本文面向需要審查資料轉換的開發者與技術主管,處理資訊型搜尋意圖:判定去重是否維持既有結果。來源筆記提供「集合語意與計數語意必須分開」的核心觀點;本文只抽象化概念,不使用來源中的組織、實體、案例數字或操作指令。所有可執行範例皆為自行設計的合成標籤資料,只在記憶體中運算。

先判定消費端是否依賴出現次數

去重是否保持答案,取決於消費端使用相異值還是出現次數。存在性與集合成員通常不受完全相同的重複列影響,但逐列計數、加總及順序相關操作可能改變結果,必須逐項驗證。

Python 官方文件將 set 定義為不重複、可雜湊物件的無序集合;Counter 則保存元素的計數。這個差異提供清楚的思考工具:集合回答「有哪些」,多重集合回答「每一種有幾次」。兩者不能互相替代,也不能因為畫面只顯示一次,就推定所有消費端都只使用集合。

可以把候選轉換記為 D,下游函式記為 f。保留結果的要求是:在約定的輸入範圍內,f(rows) 與 f(D(rows)) 相等。這是有條件的契約,不是任意資料都成立的保證。尤其是先排序再取第一筆、先分組再計算權重、或列舉時附帶其他行為的程式,都需要額外分析。

審查重點不是搜尋到哪個函式名稱,而是輸入如何一路變成輸出。 len(set(values)) 計算相異值數量,與 len(values) 並非同一種需求;看見計數運算也不能直接判定不安全。應沿著資料流確認計數前是否已投影、去重,以及相等比較使用哪些欄位。

定義完整身分與相等比較的邊界

正確去重必須先定義完整身分鍵與有效範圍,再決定非鍵欄位如何處理。只有鍵相同但內容不同的列,不能自動視為可丟棄副本;遇到衝突應停止轉換,交由明確規則處理。

本文使用四個欄位:範圍、物件、標籤、權重。身分鍵是前三個欄位,權重屬於內容。不同範圍下即使物件與標籤相同,也仍是不同關係。如果漏掉範圍,去重程式就會把原本應保留的關係合併。這種錯誤往往不會產生例外,卻會讓結果悄悄縮小。

完全相同的 tuple 可以直接建立唯一投影;但「身分相同、權重不同」需要衝突偵測。範例採取保守規則:同鍵資料的完整內容必須相同,否則拋出 ValueError。這不是通用合併政策,而是避免任意選第一列或最後一列的示範。

相等比較還可能涉及大小寫、空白、缺值與文字正規化。本文只使用固定的 ASCII 字串及整數,不主張 Python 比較規則等同其他儲存系統的定序規則。若測試資料加入空值或不同文字形式,應先說清楚哪些差異有意義,再擴充身分函式與測試。

建立消費端清單,不以整體平均代替證據

去重審查應列出所有已知消費端,逐一記錄使用欄位、分組方式與預期輸出。整體平均相同無法證明個別結果相同;應以同一組合成輸入比較前後答案,並明確標示尚未覆蓋的路徑。

可以先建立簡單的審查表,每列包含消費端名稱、讀取的資料形狀、是否依賴出現次數、是否依賴順序,以及驗收方式。存在性查詢與計數報表即使讀同一份資料,也應分開列。只測一個典型畫面,容易遺漏彙總、匯出或背景運算的不同需求。

比對時優先使用同一輸入的前後配對,而不是把「重複多」與「重複少」的兩群資料直接比較。不同群體可能在其他屬性上不同;觀察到平均值差異,不代表重複本身造成差異。分組檢查可協助定位,但仍應回到函式契約與逐筆對照,不能把統計相關性當作語意證明。

本文的合成測試故意保留會失敗的反例。若檢查工具只報告集合相等,卻完全不顯示計數與加總改變,它會造成過度樂觀的結論。測試應同時展示哪些觀察值保持不變,以及哪些觀察值確實受到影響,讓審查者知道證據涵蓋的邊界。

驗證與重現:固定版本的記憶體實驗

在本機以固定版本執行合成資料實驗,可以同時驗證集合不變、計數改變與身分鍵邊界。以下程式不讀取外部資料、不連接服務、不修改既有檔案,任何斷言不符都會以非零狀態結束。

前置條件為 Python 3.11.15 與可執行本機程式的隔離測試目錄,不需安裝第三方套件。固定版本是重現條件,不表示推薦永久停留在此修補版本。移到其他版本時,應另外重跑並記錄結果,而不是把本次查核當作相容性保證。

  1. 將下方完整程式存為 dedup_semantics_lab.py,執行 python --version,確認版本為 Python 3.11.15。版本不符先停止,本範例不包含安裝或切換系統環境的操作。
  2. 執行 python dedup_semantics_lab.py,不可加上 -O。檢查 check1 的三項結果皆為 true;check2 應顯示列數由 4 變 3、加總由 11 變 9,而 countsEqual 為 false。這個不相等是刻意驗證的反例,不是測試失敗。
  3. 檢查 check3:完整身分數為 3,省略範圍後為 2;衝突內容被拒絕、重跑投影保持相同,以及原始輸入不變都應為 true。主程式退出碼必須為 0 且 passed 為 true;任一條件不符即未通過。
"""Synthetic in-memory checks only; no files, services, or external data accessed."""
from collections import Counter
import json
import platform
import sys

if sys.version_info[:3] != (3, 11, 15):
    raise SystemExit('Requires Python 3.11.15')
if not __debug__:
    raise SystemExit('Run without -O')

"""Each tuple is (scope, object, label, weight). All values are invented."""
rows = [
    ('blue', 'item-a', 'round', 2),
    ('blue', 'item-a', 'round', 2),
    ('blue', 'item-a', 'square', 5),
    ('green', 'item-a', 'round', 2),
]
original = list(rows)
unique = list(dict.fromkeys(rows))

def membership(data):
    return set((scope, obj, label) for scope, obj, label, weight in data)

def exists_round(data):
    return any(label == 'round' for scope, obj, label, weight in data)

def identity(row):
    return row[:3]

def checked_projection(data):
    by_key = {}
    for row in data:
        key = identity(row)
        if key in by_key and by_key[key] != row:
            raise ValueError('Conflicting payload for declared identity')
        by_key[key] = row
    return list(by_key.values())

before_set = membership(rows)
after_set = membership(unique)
assert before_set == after_set
assert exists_round(rows) == exists_round(unique) is True
assert exists_round([]) is False
assert membership([]) == set()
check1 = {'membershipEqual': True, 'existenceEqual': True, 'emptyHandled': True}

before_counts = Counter(identity(row) for row in rows)
after_counts = Counter(identity(row) for row in unique)
before_sum = sum(row[3] for row in rows)
after_sum = sum(row[3] for row in unique)
assert len(rows) == 4 and len(unique) == 3
assert before_counts[('blue', 'item-a', 'round')] == 2
assert after_counts[('blue', 'item-a', 'round')] == 1
assert before_counts != after_counts
assert before_sum == 11 and after_sum == 9
check2 = {'rowsBefore': len(rows), 'rowsAfter': len(unique),
          'countsEqual': before_counts == after_counts,
          'sumBefore': before_sum, 'sumAfter': after_sum}

projected = checked_projection(rows)
assert membership(projected) == before_set
assert checked_projection(projected) == projected
bad_keys = set((obj, label) for scope, obj, label, weight in rows)
assert len(bad_keys) == 2 and len(before_set) == 3
conflict_rejected = False
try:
    checked_projection(rows + [('blue', 'item-a', 'round', 8)])
except ValueError:
    conflict_rejected = True
assert conflict_rejected
assert rows == original
check3 = {'scopedIdentities': len(before_set), 'unscopedIdentities': len(bad_keys),
          'conflictingPayloadRejected': conflict_rejected, 'idempotent': True,
          'inputUnchanged': rows == original}
print(json.dumps({'python': platform.python_version(), 'check1': check1,
                  'check2': check2, 'check3': check3, 'passed': True}, indent=2))

排程實際執行結果: Python 3.11.15,退出碼 0,passed=true。集合與存在性檢查通過;計數不相等與加總改變均符合反例;範圍鍵、衝突拒絕、冪等性與輸入不變檢查通過。這些數字只屬於可見的合成資料,不是任何組織的資料量、改善幅度或效能測量。

如何解讀通過結果與測試限制

本實驗通過只代表指定合成輸入符合已寫出的契約,不代表所有消費端都可安全去重。集合相等、冪等性與輸入不變是不同檢查,必須一起理解,也不能取代未納入測試的規則。

集合相等證明這組資料的成員沒有變,但不證明順序不變。Python 的集合不保存插入位置;若消費端依賴順序,必須使用獨立的順序契約。範例的唯一投影使用 dictionary 保留首次出現的列序,而集合只用於比較成員,兩個用途刻意分離。

冪等性表示對已轉換結果再做一次相同轉換,不會繼續改變它;它不能單獨證明第一次轉換正確。一個把所有資料都變成空集合的錯誤函式也可能冪等。因此範例同時比較完整成員集合,並加入省略範圍的反例,避免把形式上的穩定誤認為語意正確。

輸入不變則界定副作用。本程式只建立新的 list、set 與 dictionary,不會就地刪除原始列。對一般可變物件而言,淺複製不保證深層內容隔離;這裡使用不可變字串、整數與 tuple,因此檢查的範圍很清楚。不要直接把這項結果套用到含有巢狀可變物件的資料結構。

採用清單與來源查核

採用去重轉換前,先完成身分定義、消費端分類與反例測試,再提出可審查的變更範圍。若另有既有計算錯誤,應獨立處理,避免把結果修正混入原本承諾保持答案不變的重構。

  • 資料身分: 完整鍵是否包含範圍?非鍵內容衝突是否停止?
  • 消費端: 集合、出現次數、順序與副作用是否分開審查?
  • 測試資料: 是否包含空輸入、完全重複、跨範圍同名及內容衝突?
  • 結果證據: 是否保存版本、指令、退出碼,以及相同與不同的觀察值?
  • 變更邊界: 是否只提出候選投影,不混入外部資料操作或其他規則修正?

本篇位於 MOFU 技術評估層:協助讀者判斷資料轉換設計是否可被信任。若之後評估內容成效,可追蹤 GSC 的相關查詢曝光與 CTR,以及測試段落的閱讀互動;這些是待觀測指標,不是本次已取得的成果。工程驗收則追蹤已分類消費端覆蓋率與未解釋的輸出差異,不以去除多少列當作正確性指標。

已開啟並查核的官方參考:

官方線上文件可能隨修補版本更新;本文實際執行基準固定為 Python 3.11.15。文件用來查核語意,實驗用來驗證限定範例,兩者都不支持效能提升承諾。先執行反例,再確認完整身分,最後才判定哪些消費端允許去重。