typingnorm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
typingnorm/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """typingnorm — 人類打字的常模,以及據此判斷輸入是否為真人的判準。
2
+
3
+ `profile.json` 是常模本身:擬合自 KeyRecs(CC BY 4.0,可商業使用)的擊鍵時序分佈。
4
+ `detect.check()` 拿觀測到的擊鍵去比對常模。`simulate` 反過來從常模抽樣產生擊鍵,
5
+ 主要用途是給偵測端當測試對手。
6
+
7
+ 每條判準的誤判率都在另一份獨立資料集(Aalto 136M Keystrokes,2,245 位受試者)上
8
+ 量過,方法與數字見 METHOD.md。
9
+ """
10
+
11
+ from .detect import RULES, Report, check
12
+ from .metrics import (DEFAULT_METRIC, METRICS, Session, all_scores,
13
+ load_session, score)
14
+
15
+ __version__ = "0.1.0"
16
+ __all__ = [
17
+ "check", "Report", "RULES",
18
+ "score", "all_scores", "Session", "load_session", "METRICS", "DEFAULT_METRIC",
19
+ ]
typingnorm/detect.py ADDED
@@ -0,0 +1,192 @@
1
+ """判斷一段擊鍵輸入是否具有合成的跡象。
2
+
3
+ 設計原則有三條,都是被資料逼出來的,不是選來好看的。
4
+
5
+ **只用速度不變的判準。** 打字快與慢的人,rollover、間隔、dwell 全都差很多。任何
6
+ 「離人類平均多遠」的判準都會系統性誤判速度極端的人,那恰好是最不該被誤判的族群。
7
+
8
+ **優先用物理上不可能,而不是統計上罕見。** 同一個實體鍵不可能在放開前再次按下;
9
+ 每個鍵按住的時間不可能完全一樣。這類判準跟打字快慢無關。
10
+
11
+ **回報訊號,不回報布林值。** 一個回傳 is_human 的函式會誘導呼叫端拿它當閘門,而
12
+ 這種方法只擋得住不用心的自動化。刻意模仿的程式可以通過全部判準。
13
+
14
+ 每條判準的誤判率都在 Aalto 136M Keystrokes 的 2,245 位受試者上量過,見 METHOD.md
15
+ 第 7.2 節。誤判率寫在 RULES 裡,呼叫端據此決定要不要採信。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from dataclasses import dataclass, field
21
+ from typing import Iterable, Literal, Sequence
22
+
23
+ # 選字、確認、切換輸入法的鍵不是產生文字的擊鍵,一律排除
24
+ NAV_PREFIXES = ("Arrow", "Meta", "Shift", "Control", "Alt", "Tab", "Escape",
25
+ "Enter", "Page", "Home", "End", "CapsLock", "Fn", "OS")
26
+ # 修飾鍵左右手同碼名的問題:左右 Shift 若被正規化成同一個名字,會產生假的同鍵重疊
27
+ MODIFIERS = ("Shift", "Control", "Alt", "Meta", "OS", "CapsLock", "Fn")
28
+
29
+ MIN_KEYSTROKES = 150 # 低於此樣本數不下任何結論
30
+ ROLLOVER_MIN_WPM = 35.0 # 慢速真人本來就接近零重疊,這條判準不適用
31
+ MIN_HUMAN_DWELL_MS = 5.0 # 真人 dwell 中位數約 91ms,低於這個值是物理上不可能
32
+
33
+ Verdict = Literal["human-consistent", "synthetic-signals", "insufficient-data"]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Rule:
38
+ id: str
39
+ what: str
40
+ false_positive: float | None # 在 2,245 位真人上的誤判率,None 表示未量測
41
+
42
+
43
+ RULES = {
44
+ "same_key_overlap": Rule(
45
+ "same_key_overlap",
46
+ "同一個實體鍵在放開前又被按下。手指做不到,作業系統的行為也未定義。",
47
+ 0.0000,
48
+ ),
49
+ "constant_dwell": Rule(
50
+ "constant_dwell",
51
+ "按住時間不像真的:每個鍵按住的時間幾乎完全一樣,或短到物理上不可能。"
52
+ "真人的 dwell 變異係數沒有低於 0.05 的,中位數也沒有低於 20ms 的。",
53
+ 0.0000,
54
+ ),
55
+ "zero_rollover": Rule(
56
+ "zero_rollover",
57
+ "完全沒有按鍵重疊。只在 35 WPM 以上套用,因為慢速真人本來就接近零。",
58
+ 0.0031,
59
+ ),
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class Report:
65
+ verdict: Verdict
66
+ flags: list[str] = field(default_factory=list)
67
+ measures: dict[str, float | None] = field(default_factory=dict)
68
+ n_keystrokes: int = 0
69
+ note: str = ""
70
+
71
+ @property
72
+ def false_positive_budget(self) -> float:
73
+ """觸發的判準當中最高的誤判率。呼叫端用它決定要不要採信這次判定。"""
74
+ rates = [RULES[f].false_positive for f in self.flags
75
+ if RULES[f].false_positive is not None]
76
+ return max(rates) if rates else 0.0
77
+
78
+ def as_dict(self) -> dict:
79
+ return {
80
+ "verdict": self.verdict,
81
+ "flags": [{"rule": f, "what": RULES[f].what,
82
+ "false_positive": RULES[f].false_positive} for f in self.flags],
83
+ "measures": self.measures,
84
+ "n_keystrokes": self.n_keystrokes,
85
+ "note": self.note,
86
+ }
87
+
88
+
89
+ def _is_nav(code: str) -> bool:
90
+ return code.startswith(NAV_PREFIXES)
91
+
92
+
93
+ def _is_modifier(code: str) -> bool:
94
+ return code.startswith(MODIFIERS)
95
+
96
+
97
+ def _pair(events: Sequence[tuple]) -> tuple[list[tuple[float, float, str]], int]:
98
+ """把事件配成 (按下, 放開, 鍵碼)。回傳配對結果與同鍵重疊的次數。
99
+
100
+ 事件一律先按時間戳排序:瀏覽器的派送順序與時間戳不一致,實測看過時間戳較早的
101
+ keyup 在較晚的事件之後才送達。
102
+ """
103
+ ordered = sorted(events, key=lambda e: e[0])
104
+ down: dict[str, float] = {}
105
+ presses: list[tuple[float, float, str]] = []
106
+ overlaps = 0
107
+
108
+ for t, kind, code, *_ in ordered:
109
+ if kind not in ("D", "U"):
110
+ raise ValueError(f"事件類型必須是 'D' 或 'U',收到 {kind!r}")
111
+ if kind == "D":
112
+ if code in down and not _is_modifier(code):
113
+ # 修飾鍵排除在外,因為左右 Shift 常被正規化成同一個名字,
114
+ # 那會製造假的重疊。實測 2,245 人裡有 15 人因此被誤判。
115
+ overlaps += 1
116
+ down[code] = t
117
+ else:
118
+ start = down.pop(code, None)
119
+ if start is not None:
120
+ presses.append((start, t, code))
121
+
122
+ presses.sort()
123
+ return presses, overlaps
124
+
125
+
126
+ def _cv(xs: Sequence[float]) -> float | None:
127
+ if len(xs) < 2:
128
+ return None
129
+ m = sum(xs) / len(xs)
130
+ if m <= 0:
131
+ return None
132
+ return math.sqrt(sum((x - m) ** 2 for x in xs) / len(xs)) / m
133
+
134
+
135
+ def check(events: Iterable[tuple], min_keystrokes: int = MIN_KEYSTROKES) -> Report:
136
+ """檢查一段擊鍵事件。
137
+
138
+ events 是 (時間毫秒, 'D' 或 'U', 實體鍵碼, 可省略的 key) 的序列。**鍵碼必須是
139
+ 實體鍵**(瀏覽器的 event.code),不是字元,否則同鍵重疊那條判準會失準。
140
+
141
+ 回傳的是訊號不是判決。verdict 為 human-consistent 只代表沒有觸發任何判準,
142
+ 不代表對方一定是人:刻意模仿真人時序的程式可以全部通過。
143
+ """
144
+ presses, overlaps = _pair(events)
145
+ text = [p for p in presses if not _is_nav(p[2])]
146
+
147
+ if len(text) < min_keystrokes:
148
+ return Report(
149
+ verdict="insufficient-data",
150
+ n_keystrokes=len(text),
151
+ note=f"只有 {len(text)} 次擊鍵,低於 {min_keystrokes} 的門檻。"
152
+ f"樣本不足時所有統計都不可靠,不應據此下任何結論。",
153
+ )
154
+
155
+ dwell = [(u - d) for d, u, _ in text]
156
+ iki = [text[i + 1][0] - text[i][0] for i in range(len(text) - 1)]
157
+ rollover = sum(1 for i in range(len(text) - 1) if text[i + 1][0] < text[i][1])
158
+ mean_iki = sum(iki) / len(iki) if iki else 0.0
159
+ wpm = 12000.0 / mean_iki if mean_iki > 0 else 0.0
160
+ roll_pct = rollover / len(iki) * 100 if iki else 0.0
161
+ dwell_cv = _cv(dwell)
162
+
163
+ flags: list[str] = []
164
+ if overlaps > 0:
165
+ flags.append("same_key_overlap")
166
+ dwell_median = sorted(dwell)[len(dwell) // 2] if dwell else 0.0
167
+ # 變異係數在 dwell 全為零時無定義,而全為零正是最退化的合成輸入,
168
+ # 所以要另外擋。只看變異係數會讓這種輸入整條溜過去。
169
+ if dwell_median < MIN_HUMAN_DWELL_MS or (dwell_cv is not None and dwell_cv < 0.05):
170
+ flags.append("constant_dwell")
171
+ if rollover == 0 and wpm >= ROLLOVER_MIN_WPM:
172
+ flags.append("zero_rollover")
173
+
174
+ note = ("觸發的判準只證明這串輸入不像手打的。沒有觸發也不證明是人,"
175
+ "刻意模仿真人時序的程式可以通過全部判準。")
176
+ if not flags and wpm < ROLLOVER_MIN_WPM:
177
+ note += f" 另外此次速度 {wpm:.0f} WPM 低於 {ROLLOVER_MIN_WPM:.0f},零重疊判準未套用。"
178
+
179
+ return Report(
180
+ verdict="synthetic-signals" if flags else "human-consistent",
181
+ flags=flags,
182
+ measures={
183
+ "wpm": round(wpm, 1),
184
+ "iki_median_ms": round(sorted(iki)[len(iki) // 2], 1) if iki else None,
185
+ "dwell_median_ms": round(dwell_median, 1) if dwell else None,
186
+ "dwell_cv": round(dwell_cv, 4) if dwell_cv is not None else None,
187
+ "rollover_pct": round(roll_pct, 2),
188
+ "same_key_overlaps": overlaps,
189
+ },
190
+ n_keystrokes=len(text),
191
+ note=note,
192
+ )
typingnorm/metrics.py ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """打字速度指標。以下常數與規則的來源見 METHOD.md。
3
+
4
+ 每個語言有慣用的算法,使用者也可以切換成別的。
5
+
6
+ 設計前提:**永遠不存分數,只存原始事件**。分數是顯示時才從事件流算出來的。
7
+ 任何一個地方存了分數,之後換算法就得重收資料。
8
+
9
+ 每個指標都是純函式 (Session) -> float。要加新語言就加一個函式,不動其他東西。
10
+
11
+ 單位的來源分兩種,`authoritative` 欄位標示得很清楚:
12
+ True 出自國家級認證機構,數字對當地人有意義(80 字/分是 TQC 專業級)
13
+ False 業界慣例或本站自訂,介面上不得出現「合格」「專業級」這類字眼
14
+ """
15
+ import json
16
+ import sys
17
+ from dataclasses import dataclass, field
18
+
19
+ # 注音符號與聲調,用來判斷哪些擊鍵屬於組字過程
20
+ BOPOMOFO = set(range(0x3105, 0x3130)) | {0x02C7, 0x02CA, 0x02CB, 0x02D9}
21
+ # 韓文字母,타수 是按字母鍵計數(한 = ㅎ+ㅏ+ㄴ = 3 타)
22
+ HANGUL_JAMO = set(range(0x3131, 0x3164))
23
+
24
+
25
+ @dataclass
26
+ class Session:
27
+ """一次練習的原始紀錄。這是唯一被儲存的東西。"""
28
+ language: str # zh-TW / en / ja / ko / hi / th ...
29
+ script: str # han-trad / latin / kana / hangul / thai ...
30
+ input_method: str # zhuyin / pinyin / direct / inscript / telex ...
31
+ layout: str # qwerty / dachen / kedmanee / dubeolsik ...
32
+ prompt: str # 題目原文
33
+ typed: str # 實際打出的內容
34
+ seconds: float # 從第一次按鍵到最後一次按鍵
35
+ keystrokes: int # 按下的總次數,含修正與組字
36
+ events: list = field(default_factory=list) # [(t, 'D'|'U', code, key)]
37
+
38
+ def correct_chars(self):
39
+ """逐位置比對,回傳正確字元數。"""
40
+ return sum(1 for a, b in zip(self.prompt, self.typed) if a == b)
41
+
42
+ def errors(self):
43
+ """錯打、多打、漏打合計。顛倒字需要更細的比對,見 TQC 規則。"""
44
+ wrong = sum(1 for a, b in zip(self.prompt, self.typed) if a != b)
45
+ return wrong + abs(len(self.prompt) - len(self.typed))
46
+
47
+ def minutes(self):
48
+ return max(self.seconds, 1e-9) / 60.0
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # 指標實作
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def kpm(s):
56
+ """每分鐘擊鍵數。唯一在所有書寫系統都成立的指標,因為每種文字都用鍵盤打。
57
+ 字數與詞數都是各語言自己的抽象,擊鍵數才是共同分母,跨語言比較要用這個。"""
58
+ return s.keystrokes / s.minutes()
59
+
60
+
61
+ def wpm_net_5(s):
62
+ """英文慣例:五個字元算一個詞,淨速度扣掉錯誤。印地文與阿拉伯文沿用。"""
63
+ return (s.correct_chars() / 5 - s.errors() / 5) / s.minutes()
64
+
65
+
66
+ def kdph_ssc(s):
67
+ """印度公職考試的正式單位:每小時按鍵次數。
68
+ SSC CHSL 英文門檻 10,500 KDPH(= 35 WPM),印地文 9,000(= 30 WPM)。
69
+ 換算關係 KDPH = WPM × 300。"""
70
+ return s.keystrokes * 3600.0 / max(s.seconds, 1e-9)
71
+
72
+
73
+ def cpm_tqc(s):
74
+ """台灣 TQC 中文輸入:每分鐘淨字數,每錯一次扣 0.5 字。
75
+ 錯誤率 ≥ 10% 該次成績不予計算,此處回傳 0 表示不計。
76
+ 級別:實用 15、進階 30、專業 80。"""
77
+ total = max(len(s.prompt), 1)
78
+ if s.errors() / total >= 0.10:
79
+ return 0.0
80
+ return max(0.0, s.correct_chars() - 0.5 * s.errors()) / s.minutes()
81
+
82
+
83
+ def cpm_jp(s):
84
+ """日文:純字数/分。全商速度部門 1 級為 10 分鐘 700 字,即 70 字/分。"""
85
+ return max(0.0, s.correct_chars() - s.errors()) / s.minutes()
86
+
87
+
88
+ def tasu(s):
89
+ """韓文 타수:按字母鍵計數,不是按字計數。
90
+ 「한」是 ㅎ+ㅏ+ㄴ 三打。所以這個指標只能從擊鍵流算,從輸出文字算不出來。
91
+ 워드프로세서 1 급門檻 300 타,一般成人 200 到 300。"""
92
+ jamo = sum(1 for e in s.events
93
+ if e[1] == 'D' and e[3] and ord(e[3][0]) in HANGUL_JAMO)
94
+ return (jamo or s.keystrokes) / s.minutes()
95
+
96
+
97
+ def wpm_4(s):
98
+ """泰文慣例:四個字元算一個詞。來源只有社群與練習網站,非官方標準。"""
99
+ return (s.correct_chars() / 4 - s.errors() / 4) / s.minutes()
100
+
101
+
102
+ def cpm_raw(s):
103
+ """每分鐘正確字元數。沒有當地標準時的中性預設。"""
104
+ return s.correct_chars() / s.minutes()
105
+
106
+
107
+ METRICS = {
108
+ 'kpm': (kpm, '打鍵/分', False, '跨語言比較用的共同分母'),
109
+ 'wpm_net_5': (wpm_net_5, 'WPM', False, '英文慣例,五字元一詞'),
110
+ 'kdph_ssc': (kdph_ssc, 'KDPH', True, 'SSC 公職考試,10500 = 35 WPM'),
111
+ 'cpm_tqc': (cpm_tqc, '字/分', True, 'TQC,專業級 80'),
112
+ 'cpm_jp': (cpm_jp, '字/分', True, '全商速度部門,1 級 70'),
113
+ 'tasu': (tasu, '타수', True, '워드프로세서 1급 300'),
114
+ 'wpm_4': (wpm_4, 'คำ/นาที', False, '泰文社群慣例,四字元一詞'),
115
+ 'cpm_raw': (cpm_raw, '字元/分', False, '中性預設'),
116
+ }
117
+
118
+ # 語言的預設指標。使用者可以切換成 METRICS 裡的任何一個。
119
+ DEFAULT_METRIC = {
120
+ 'zh-TW': 'cpm_tqc', 'zh-HK': 'cpm_tqc', 'zh-CN': 'cpm_raw',
121
+ 'ja': 'cpm_jp', 'ko': 'tasu', 'hi': 'kdph_ssc',
122
+ 'en': 'wpm_net_5', 'ar': 'wpm_net_5', 'th': 'wpm_4', 'vi': 'kpm',
123
+ }
124
+
125
+
126
+ def score(session, metric=None):
127
+ """算分。不指定就用該語言的慣用指標。"""
128
+ key = metric or DEFAULT_METRIC.get(session.language, 'cpm_raw')
129
+ fn, unit, authoritative, note = METRICS[key]
130
+ return {
131
+ 'metric': key, 'value': round(fn(session), 1), 'unit': unit,
132
+ 'authoritative': authoritative, 'note': note,
133
+ }
134
+
135
+
136
+ def all_scores(session):
137
+ """全部算一遍,給切換用。介面只是換一個已經算好的值,不用重打。"""
138
+ return [score(session, k) for k in METRICS]
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def _demo():
144
+ """對照公開的門檻值驗算,公式錯了這裡就會失敗。"""
145
+ # 印度 SSC:35 WPM 英文應該剛好是 10,500 KDPH
146
+ en = Session('en', 'latin', 'direct', 'qwerty',
147
+ prompt='x' * 175, typed='x' * 175, seconds=60,
148
+ keystrokes=175)
149
+ assert abs(score(en, 'wpm_net_5')['value'] - 35.0) < 0.01
150
+ assert abs(score(en, 'kdph_ssc')['value'] - 10500.0) < 1
151
+
152
+ # 印地文 30 WPM 應該是 9,000 KDPH
153
+ hi = Session('hi', 'devanagari', 'inscript', 'inscript',
154
+ prompt='x' * 150, typed='x' * 150, seconds=60, keystrokes=150)
155
+ assert abs(score(hi)['value'] - 9000.0) < 1
156
+ assert score(hi)['authoritative'] is True
157
+
158
+ # TQC:80 字全對,一分鐘,應為專業級 80
159
+ tw = Session('zh-TW', 'han-trad', 'zhuyin', 'dachen',
160
+ prompt='字' * 80, typed='字' * 80, seconds=60, keystrokes=320)
161
+ assert abs(score(tw)['value'] - 80.0) < 0.01
162
+ # 錯四個字:正確 76、錯誤 4,扣 2 字,應為 74
163
+ tw_err = Session('zh-TW', 'han-trad', 'zhuyin', 'dachen',
164
+ prompt='字' * 80, typed='字' * 76 + '錯' * 4,
165
+ seconds=60, keystrokes=320)
166
+ assert abs(score(tw_err)['value'] - 74.0) < 0.01
167
+ # 錯誤率達 10% 不予計算
168
+ tw_bad = Session('zh-TW', 'han-trad', 'zhuyin', 'dachen',
169
+ prompt='字' * 80, typed='字' * 72 + '錯' * 8,
170
+ seconds=60, keystrokes=320)
171
+ assert score(tw_bad)['value'] == 0.0
172
+
173
+ # 韓文:타수 從擊鍵流算。「한」三個字母鍵,打二十次即 60 타/分
174
+ ev = [(i * 100.0, 'D', 'KeyR', c) for i, c in enumerate('ㅎㅏㄴ' * 20)]
175
+ ko = Session('ko', 'hangul', 'direct', 'dubeolsik',
176
+ prompt='한' * 20, typed='한' * 20, seconds=60,
177
+ keystrokes=60, events=ev)
178
+ assert abs(score(ko)['value'] - 60.0) < 0.01
179
+
180
+ # 同一場練習換指標,值要跟著換,原始資料完全沒動
181
+ assert len({s['metric'] for s in all_scores(tw)}) == len(METRICS)
182
+
183
+ print('metrics 自檢通過:SSC / TQC / 全商 / 워드프로세서 的門檻換算都對得上')
184
+ print()
185
+ print('同一場練習(TQC 專業級 80 字/分)換成各種指標:')
186
+ for s in all_scores(tw):
187
+ flag = '官方' if s['authoritative'] else '慣例'
188
+ print(f" {s['metric']:11s} {s['value']:9.1f} {s['unit']:9s} [{flag}] {s['note']}")
189
+
190
+
191
+
192
+
193
+ def load_session(path):
194
+ """從 session JSON 載入。欄位缺漏在這裡就會炸,不會拖到分析階段才發現。"""
195
+ with open(path, encoding='utf-8') as f:
196
+ d = json.load(f)
197
+ for k in ('schema', 'session_id', 'locale', 'prompt', 'typed', 'events'):
198
+ if k not in d:
199
+ raise ValueError(f'{path} 缺少必填欄位 {k}')
200
+ ev = sorted(d['events'], key=lambda e: e[0]) # 派送順序不可信,一律重排
201
+ downs = [e for e in ev if e[1] == 'D']
202
+ return Session(
203
+ language=d['locale']['language'], script=d['locale']['script'],
204
+ input_method=d['locale']['input_method'], layout=d['locale']['layout'],
205
+ prompt=d['prompt'], typed=d['typed'],
206
+ seconds=(ev[-1][0] - ev[0][0]) / 1000.0 if ev else 0.0,
207
+ keystrokes=len(downs), events=[(e[0], e[1], e[2], e[3]) for e in ev],
208
+ )
209
+
210
+
211
+ if __name__ == '__main__':
212
+ if len(sys.argv) > 1 and sys.argv[1] == '--json':
213
+ print(json.dumps({k: {'unit': v[1], 'authoritative': v[2], 'note': v[3]}
214
+ for k, v in METRICS.items()}, ensure_ascii=False, indent=2))
215
+ else:
216
+ _demo()
@@ -0,0 +1,108 @@
1
+ {
2
+ "schema": 1,
3
+ "source": {
4
+ "name": "KeyRecs: A keystroke dynamics and typing pattern recognition dataset",
5
+ "authors": "Dias, Vitorino, Maia, Sousa, Praça",
6
+ "venue": "Data in Brief, 2023",
7
+ "doi": "10.1016/j.dib.2023.109509",
8
+ "url": "https://zenodo.org/records/7886743",
9
+ "license": "CC BY 4.0",
10
+ "commercial_use": true
11
+ },
12
+ "sample": {
13
+ "participants": 99,
14
+ "digraphs": 562372,
15
+ "wpm_median": 48.5
16
+ },
17
+ "speed_definition": "級距以受試者的吞吐量指派:WPM = 12 / mean(IKI)。模型取該級距的 iki_median 當對數常態的中位數(mu = ln(iki_median)),長尾由停頓層補上,因為真實 IKI 分佈比對數常態更尖峰厚尾。",
18
+ "bands": {
19
+ "30": {
20
+ "sigma": 0.7117,
21
+ "iki_median": 0.246,
22
+ "dwell_letter": 0.083,
23
+ "cv": 0.839,
24
+ "rollover_pct": 2.0,
25
+ "n": 68400
26
+ },
27
+ "40": {
28
+ "sigma": 0.7052,
29
+ "iki_median": 0.204,
30
+ "dwell_letter": 0.09,
31
+ "cv": 0.877,
32
+ "rollover_pct": 10.7,
33
+ "n": 133730
34
+ },
35
+ "50": {
36
+ "sigma": 0.6343,
37
+ "iki_median": 0.178,
38
+ "dwell_letter": 0.095,
39
+ "cv": 0.85,
40
+ "rollover_pct": 13.1,
41
+ "n": 130292
42
+ },
43
+ "60": {
44
+ "sigma": 0.612,
45
+ "iki_median": 0.155,
46
+ "dwell_letter": 0.096,
47
+ "cv": 0.816,
48
+ "rollover_pct": 22.5,
49
+ "n": 112555
50
+ },
51
+ "70": {
52
+ "sigma": 0.5773,
53
+ "iki_median": 0.14,
54
+ "dwell_letter": 0.087,
55
+ "cv": 0.798,
56
+ "rollover_pct": 22.2,
57
+ "n": 49268
58
+ },
59
+ "80": {
60
+ "sigma": 0.6267,
61
+ "iki_median": 0.122,
62
+ "dwell_letter": 0.092,
63
+ "cv": 0.817,
64
+ "rollover_pct": 31.0,
65
+ "n": 43255
66
+ }
67
+ },
68
+ "dwell": {
69
+ "letter_sigma": 0.3429,
70
+ "space": [
71
+ 0.091,
72
+ 0.3263
73
+ ],
74
+ "shift": [
75
+ 0.254,
76
+ 0.4404
77
+ ],
78
+ "backspace": [
79
+ 0.079,
80
+ 0.3394
81
+ ]
82
+ },
83
+ "error": {
84
+ "backspaces_per_char": [
85
+ 0.0676,
86
+ 0.0472,
87
+ 0.0906
88
+ ],
89
+ "note": "中位數 / p25 / p75。模型的 error_rate 應校準到重現中位數"
90
+ },
91
+ "transition": {
92
+ "same_hand": 1.1287,
93
+ "alternate_hand": 0.8904,
94
+ "same_finger": 1.0723
95
+ },
96
+ "lexical": {
97
+ "rare_word": 1.0474,
98
+ "common_word": 0.9141,
99
+ "word_edge": 0.9366,
100
+ "word_middle": 1.0778
101
+ },
102
+ "counts": {
103
+ "transition": 298920,
104
+ "lexical": 167252,
105
+ "position": 167252,
106
+ "dwell_letter": 413757
107
+ }
108
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "humantype practice session",
4
+ "description": "一次打字練習的原始紀錄。只存量測到的東西,不存任何分數:分數是顯示時才依所選指標從事件流算出來的,這樣換算法或加新指標都不必重收資料。也不存使用者自己寫的文字,因為題目是出好的。",
5
+ "type": "object",
6
+ "required": ["schema", "session_id", "started_at", "locale", "prompt_id",
7
+ "prompt", "typed", "events"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "schema": { "const": 1 },
11
+ "session_id": {
12
+ "type": "string",
13
+ "description": "匿名亂數 ID,不連結到任何身分"
14
+ },
15
+ "started_at": { "type": "string", "format": "date-time" },
16
+ "consent": {
17
+ "type": "object",
18
+ "description": "同意公開釋出的版本與時間。缺這一項的工作階段不得進入公開資料集。",
19
+ "required": ["version", "granted_at", "redistribution"],
20
+ "properties": {
21
+ "version": { "type": "string" },
22
+ "granted_at": { "type": "string", "format": "date-time" },
23
+ "redistribution": { "type": "boolean" }
24
+ }
25
+ },
26
+ "locale": {
27
+ "type": "object",
28
+ "required": ["language", "script", "input_method", "layout"],
29
+ "properties": {
30
+ "language": { "type": "string", "examples": ["zh-TW", "en", "ja", "ko", "hi", "th", "vi", "ar"] },
31
+ "script": { "type": "string", "examples": ["han-trad", "han-simp", "latin", "kana", "hangul", "devanagari", "thai", "arabic"] },
32
+ "input_method": { "type": "string", "examples": ["zhuyin", "pinyin", "cangjie", "direct", "inscript", "remington", "telex", "vni", "romaja"] },
33
+ "layout": { "type": "string", "examples": ["qwerty", "azerty", "dvorak", "dachen", "eten", "dubeolsik", "kedmanee", "pattachote"] }
34
+ }
35
+ },
36
+ "prompt_id": { "type": "string" },
37
+ "prompt": { "type": "string", "description": "題目原文" },
38
+ "typed": { "type": "string", "description": "實際打出的內容,用來比對正確率" },
39
+ "commits": {
40
+ "type": "array",
41
+ "description": "組字型輸入法專用:每個字上屏的時刻與用掉的擊鍵數。直打型語言留空。",
42
+ "items": {
43
+ "type": "object",
44
+ "required": ["i", "char", "t", "keys"],
45
+ "properties": {
46
+ "i": { "type": "integer" },
47
+ "char": { "type": "string" },
48
+ "t": { "type": "number", "description": "毫秒,相對於第一次按鍵" },
49
+ "keys": { "type": "integer" },
50
+ "code": { "type": "string", "description": "打出這個字用的輸入碼,例如 ㄐㄧㄣ" }
51
+ }
52
+ }
53
+ },
54
+ "events": {
55
+ "type": "array",
56
+ "description": "原始按鍵事件,依 timeStamp 排序。瀏覽器的派送順序與時間戳不一致,寫入前必須排序。",
57
+ "items": {
58
+ "type": "array",
59
+ "prefixItems": [
60
+ { "type": "number", "description": "毫秒,相對於第一次按鍵" },
61
+ { "enum": ["D", "U"], "description": "按下或放開" },
62
+ { "type": "string", "description": "實體鍵,event.code" },
63
+ { "type": "string", "description": "event.key,組字時會是注音符號等" }
64
+ ],
65
+ "minItems": 4, "maxItems": 4
66
+ }
67
+ },
68
+ "client": {
69
+ "type": "object",
70
+ "properties": {
71
+ "ua": { "type": "string" },
72
+ "timer_resolution_ms": { "type": "number" }
73
+ }
74
+ }
75
+ }
76
+ }