讀懂寫作過程:面向自由文本標註的擊鍵記錄
Potato 現在可以記錄標註者如何寫出自由文本答案,而不記錄他們鍵入的內容,並把停頓、修改和貼上變成可審計的標記,指出哪些回答是貼上來的而不是寫出來的。
一位眾包工作者開啟你的標註任務,讀完那段材料,切到另一個標籤頁,二十秒後回來,一段 280 字元的理由說明一下子出現在框裡。
這段理由說明沒什麼問題。它切題、語法通順、引用了材料。把它和另外四十份放在一起讀,看不出什麼異樣。你能拿來檢測它的每一個分類器,都會給你一個介於「大概是人寫的」和「說不清」之間的答案,而這正是那些分類器對大多數文本的說法。
寫完的答案不會告訴你它從哪兒來。那二十秒和那一下子會。
Potato 現在會記錄這件事。自由文本欄位可以採集一份內容無關的事件流,記下回答是怎麼產生的,把它彙總成約四十項特徵,再對結果跑一小組具名規則。它預設關閉,一行就能開啟:
keystroke_logging:
enabled: true擊鍵記錄
撰寫、謄抄、貼上
這背後的研究已經相當穩固。Crossley 和同事收集了 500 篇論辯文章,再讓另一批工作者把它們謄抄一遍,用隨機森林以 99% 的準確率把真實寫作與謄抄分開。Deane 等人和 Zhang 等人各自獨立地得到了同樣的分離結果。Asher 等人在 Prolific 上做了眾包版本,標記出擊鍵數相對於提交內容長度明顯偏低的參與者。
這個特徵在所有研究裡都一致。真實的撰寫在句子和詞語之前有更長的停頓,插入和刪除更多,按鍵間隔的方差也更大。謄抄是線性的、成串爆發的、方差很低。貼上幾乎沒有間隔可言。
這些在文本里一點都看不到。在日誌裡全都看得到。
為什麼用 beforeinput 而不是 keydown
這是決定這個功能其餘部分能不能成立的那個技術決策。
寫擊鍵記錄器最順手的做法是監聽 keydown。它也是會失敗的做法,因為貼上、拖放、IME 組字、聽寫、自動填充和撤銷都會改變欄位內容,卻一次 keydown 都不觸發。只看 keydown 的記錄器,恰好對這個功能存在的目的所要捕捉的那些情況是瞎的。
Potato 的主要訊號是 beforeinput 上的 InputEvent.inputType,它對上述所有情況都會觸發,並說明發生的是哪一種。keydown 和 keyup 仍然被監聽,但用途不同:統計一個人實際按下了多少個鍵。
這兩個數字之間的差距是採集到的最有用的東西。欄位裡出現了、卻沒有對應擊鍵的字元被記為 silent_insert_chars,它們在回答中的佔比記為 silent_insert_ratio。被頁面攔下的貼上、注入文本的擴充套件、聽寫流、往框裡填內容的指令碼:它們都不產生擊鍵,卻都產生字元。
記錄了什麼,沒記錄什麼
每個事件帶有一個時間戳、一個輸入類型、一個按鍵類別、一個游標位置和一個長度變化量:
{t_ms: 1240, input_type: "insertText", key_class: "letter", pos: 41, delta: +1}
{t_ms: 3980, input_type: "deleteContentBackward", key_class: "bksp", pos: 42, delta: -1}
{t_ms: 9120, input_type: "insertFromPaste", key_class: "unknown",pos: 43, delta: +287,
meta: {paste_source: "external", paste_hash: "sekqf3"}}
按鍵本身從不儲存,只存它屬於哪一族:letter、digit、punct、space、enter、bksp、del、nav、mod、func、unknown。貼上進來的文本被簡化成一個長度、一個來源標籤和一個按會話加鹽的雜湊。密碼欄位直接被拒絕。你沒法從事件流還原出回答,而這正是重點:事件流描述的是過程,對內容隻字不提。
貼上來源分類是把正常行為擋在標記之外的那道機制。貼上發生時,Potato 會把它和正在標註的材料、頁面上顯示的任何 AI 建議,以及欄位裡已有的內容做比對,然後只保留標籤、丟掉比對內容。引用材料讀作 instance_text。挪動自己的草稿讀作 self。兩者都不算外部插入。
六條規則,沒有任何冒充模型的東西
檢測在服務端分三層執行。只有第一層預設開啟。
第一層是六個具名標記,每個都有明確的閾值,每個都返回觸發它的特徵值:
| 標記 | 觸發條件 | 嚴重程度 |
|---|---|---|
paste_dominant | 最終文本有一半或以上來自貼上 | suspect |
silent_insertion | 插入的字元中 ≥30% 背後沒有擊鍵 | suspect |
transcription_rhythm | 節拍均勻且沒有修改且幾乎不停頓 | review |
offscreen_composition | 離開頁面 ≥10 秒後緊接著出現一大段外部插入 | suspect |
implausible_speed | 整份回答持續維持在約 180 wpm 以上 | review |
synthetic_input | 瀏覽器報告 isTrusted === false | suspect |
transcription_rhythm 是有意做成合取的。單看節拍均勻,那是打字快的人。單看從不刪東西,那是做事仔細的人。單看幾乎不停頓,那是回答短。只有三者同時成立,你才拿到謄抄的特徵,而且這條規則會完全跳過 80 字元以下的回答,那裡根本沒有節奏可讀。
第二層是校準。擊鍵特徵很大程度上取決於寫作任務,所以為一句話理由說明調好的閾值,用在五段文字上就是錯的。python -m potato.typing_detect calibrate config.yaml 會把每個界限重新擬合到你自己項目會話的某個尾部百分位上。它至少需要 30 個可用會話,而且校準值會被限制在內建預設值的 3 倍以內,這樣一個同質化的人群就沒法把閾值拖到自己的中位數上。
第三層是有監督的。如果你有標籤,fit_supervised() 會在特徵矩陣上訓練一個真正的分類器。scikit-learn 是延遲匯入的,不是 Potato 的依賴。
沒有隨附任何預訓練模型。倉庫裡沒有帶標籤的語料,而釋出一份憑空推匯出來的擬合係數等於編造一個驗證數字。隨附的是六條你能讀懂、能不同意、也能覆蓋掉的規則。
如果你想要標籤,培訓階段可以在你自己的研究內部產出它們。給標註者一個謄抄材料的熱身任務:那些會話是你的標註者在你的任務上留下的真實謄抄樣本,而他們平常的答案就是撰寫那一類。Crossley 的語料就是這麼建起來的,校準示例把這套流程端到端地搭好了。
閾值在服務端評估,從不傳送到瀏覽器。公佈它們等於精確地告訴標註者要多慢地貼上。
資料去了哪裡
原始事件流寫進 SQLite,位於 <task_dir>/project.sqlite,每個會話一行,走的是和備忘、編碼手冊相同的持久化層。事件經過差分編碼和 zlib 壓縮,實測每個事件 1.7 位元組,因此一份 500 詞的回答大約佔 5 KB。
它們刻意不進 user_state.json。那個檔案在每次儲存標註時都會被整體重寫,而一份長回答大約有 3,000 個事件。只有緊湊的摘要會映像進行為資料,鍵為 "{schema}:::{label}",這樣它就能隨標註一起進入儀表板和匯出。
培訓階段以及前置或後置問卷裡的自由文本答案也會被採集。那些頁面沒有實例 id,所以它們的會話歸到既有的 __phase_page__ 哨兵值下,改用 phase 和 page 來標識。這正是謄抄材料那一招能奏效的原因:謄抄樣本僅憑階段就能和普通答案分開。
兩種匯出都需要主動開啟。export_include_typing_dynamics: true 會在標註旁邊寫出一個 typing_dynamics.csv 附屬檔案,python -m potato.export.cli <config.yaml> --format keystrokes 把原始事件流寫入 Parquet,沒有 pyarrow 時回退到 JSONL。行為資料不會不小心進入資料集釋出。
在把它對準人之前
一個標記是供人審查的證據。它不是證明,也絕不能接到自動拒絕、扣款或封禁上。
這裡的失敗模式是冤枉一位誠實的標註者,而觸發這些規則的情況並不罕見。有些已經處理了:引用材料和挪動自己的草稿會被來源分類抑制;軟鍵盤和 IME 組字會抑制 silent_insertion,因為兩者都不能可靠地發出 keydown,否則每一次插入看起來都是無聲的。有些沒有處理。聽寫會被標記。語法擴充套件會被標記。某些輔助技術會產生不可信事件,從而觸發 synthetic_input,所以文件直白地寫著:如果你的研究對輔助技術使用者開放,你應該關掉那條規則,而不是去向一個只是在用自己工作所需工具的人解釋一個標記。
然後是那道算術題。如果你有 5% 的回答是貼上的,而你的規則標記了 5% 的會話,那麼被標記出來的大部分仍然可能是誠實的工作。在一個不端行為確實罕見的平臺上,一條哪怕誤報率不高的規則,產生的錯誤指控也會多過真正的捕獲。校準過的閾值只是把這一點擺明,而不是讓它變好:一個尾部百分位會標出任何人群的那一部分尾部,包括一個誰都沒做錯事的人群。
披露預設開啟,關掉它會在啟動時記錄一條警告。這個預設值之所以存在,是因為時間模式是一種行為生物特徵。它們可以識別一個人,也可以把不同場景下的賬號關聯起來,而研究文獻已經用它們推斷過打字技能、第二語言身份和認知負荷。報名來標句子的人沒料到會有這些。Potato 一樣都不計算,也不提供相關工具,但你留下的資料足以支撐這類分析,這是你要管的問題,不是工具的。typing_store.delete_for_user() 可以刪掉某一位參與者的事件流,fidelity: summary 保留特徵而丟掉生物特徵細節。倫理頁面提供了知情同意示例文本、留存建議,以及關於 GDPR 第 22 條、IRB 審查和平臺拒絕政策的說明。
writing_process_risk 會作為排序輔助出現在管理員儀表板的寫作過程面板裡,和既有的 suspicion_score 分開。兩個數字互不折算,所以誰也不會悄悄改變對方的含義。
怎麼開啟
這個功能隨 Potato 2.7.2 釋出。比開頭那一行更完整的配置:
keystroke_logging:
enabled: true
fidelity: events # off | summary | events
include_schemas: [rationale] # empty means every free-text field
disclose_to_annotators: true
detection:
enabled: true
on_external_insert: flag # allow | warn | block | flagon_external_insert: block 會阻止往被記錄的欄位裡貼上。它同樣會擋掉合理的引用,而且鐵了心的人可以改成手打,所以通常 flag 加人工審查是更划算的取捨。
一個可執行的項目在 examples/advanced/keystroke-logging/,校準的完整走查在 examples/advanced/keystroke-calibration/。
文件
- 擊鍵記錄 — 採集的每個欄位、摘要特徵、儲存和故障排除
- 寫作過程檢測 — 六條規則、三個層次、誤報表和引用文獻
- 擊鍵記錄倫理 — 知情同意、IRB、留存、參與者權利
- 行為追蹤 — 這個功能所處的更大的互動追蹤系統
- 品質控制 — 注意力檢查和金標準
- 管理員儀表板 — 寫作過程面板所在之處
- 在 Prolific 和 MTurk 上眾包 — 平臺關於監測和拒絕的規則
升級
pip install --upgrade potato-annotation==2.7.2現有項目在你主動要求之前不會有任何變化。keystroke_logging.enabled 預設為 false,所以升級不會開始記錄任何人。