一种基于字符的简单启发式方法优于固定计时器,且无需任何配置。公式threshold = max(5, min(chars × 1.2, 120))无需任何用户调整即可处理80% 以上的案例,而使用指数移动平均的自适应变体可以在短暂的预热期后实现个性化检测。对于需要最大鲁棒性的应用,使用中位数绝对偏差的改进 Z 分数方法可提供统计上严谨的异常值检测,即使在数据受到污染的情况下也能保持稳定。
像目前这种60秒的固定AFK计时器的核心问题在于,它完全忽略了文本长度。例如,像「ああ…」这样一行四个字的文本,阅读起来通常只需要2-3秒,因此60秒的阈值就显得过于宽松——它会将57秒的空闲时间计入阅读统计数据。反之,一段200字的文字对于学习者来说可能确实需要90秒以上才能读完,却会被错误地标记为AFK。
三种推荐算法,按复杂度排序
以下每种方法都以不同的复杂程度解决同一问题。请根据您的实施限制和精度要求进行选择。
算法 1:多层字符启发式算法无需历史数据,即可立即生效。算法 2:EMA 自适应基线算法在阅读 5-10 个文本框后即可学习个体阅读速度。算法 3:改进的 Z 分数结合 MAD 算法提供统计上最稳健的检测结果,但需要维护滚动历史窗口。
| 方法 | 代码行数 | 准确性 | 适应用户 | 冷启动 |
|---|---|---|---|---|
| 特征启发式 | 约10 | 良好(80%) | 不 | 立即的 |
| EMA自适应 | 约40 | 非常好(90%) | 是的 | 5-10个样本 |
| 修正 Z 分数 | 约60 | 优秀(95%) | 是的 | 10-20个样本 |
算法 1:基于字符的启发式算法(推荐的起点)
这种方法无需任何配置,也无需预热期。它的工作原理是根据文本长度按比例调整AFK阈值,并设定合理的最小值和最大值。
Python
def is_afk(time_seconds: float, char_count: int) -> bool: """ Simple heuristic that works without any learning. Returns True if the reading time indicates user was likely AFK. """ # Minimum threshold: even "ああ" needs reaction time MIN_THRESHOLD = 5 # Maximum threshold: beyond this is definitely AFK MAX_THRESHOLD = 120 # Time allowance per character (accounts for reading + processing) # 1.2 sec/char ≈ learner reading at 50 char/min + thinking time SECONDS_PER_CHAR = 1.2 threshold = max(MIN_THRESHOLD, min(char_count * SECONDS_PER_CHAR, MAX_THRESHOLD)) return time_seconds > threshold
为什么选择这些特定数值?日语阅读速度差异很大:母语者每分钟可阅读 500-1200 个字符( 日本教育标准为每秒 8-20 个字符),而中级学习者每分钟可阅读 150-300 个字符(每秒 2.5-5 个字符)。每字符 1.2 秒的阅读时间既考虑到了阅读速度最慢的学习者(约每分钟 50 个字符),又包含了查字典、重读和处理时间的3 倍乘数。5 秒的最低阅读时间是为了应对点击对话框的反应时间,而 120 秒的上限则避免了阅读文本过长的情况。
极端情况行为:
- 「ああ…」(4 个字符):阈值 = max(5, 4.8) = 5 秒
- 标准对话(30 个字符):阈值 = 36 秒
- 长篇叙述(100 个字符):阈值 = 120 秒(上限)
- 超长段落(200 个字符):阈值 = 120 秒(已限制)
算法 2:指数移动平均自适应阈值
这种方法会随着时间的推移学习用户的个人阅读速度,随着数据积累的增多,检测精度也会越来越高。在预热阶段,它会回退到简单的启发式方法。
Python
class AdaptiveAFKDetector: def __init__(self): self.alpha = 0.2 # EMA smoothing factor self.ema_time_per_char = None # Learned baseline self.sample_count = 0 # Warm-up settings self.MIN_SAMPLES = 5 self.FALLBACK_TIME_PER_CHAR = 1.2 # Detection settings self.ANOMALY_MULTIPLIER = 3.0 self.ABSOLUTE_MIN = 5 self.ABSOLUTE_MAX = 180 def record_reading(self, time_seconds: float, char_count: int) -> None: """Call after user advances to next line (confirmed not AFK).""" if char_count < 2: # Skip very short lines return time_per_char = time_seconds / char_count # Clamp extreme values to avoid polluting baseline time_per_char = max(0.1, min(time_per_char, 5.0)) if self.ema_time_per_char is None: self.ema_time_per_char = time_per_char else: # EMA: new = α × current + (1-α) × old self.ema_time_per_char = ( self.alpha * time_per_char + (1 - self.alpha) * self.ema_time_per_char ) self.sample_count += 1 def is_afk(self, time_seconds: float, char_count: int) -> bool: """Returns True if reading time indicates AFK.""" if self.sample_count < self.MIN_SAMPLES: # Warm-up: use generous fallback base = self.FALLBACK_TIME_PER_CHAR else: base = self.ema_time_per_char threshold = char_count * base * self.ANOMALY_MULTIPLIER threshold = max(self.ABSOLUTE_MIN, min(threshold, self.ABSOLUTE_MAX)) return time_seconds > threshold
为什么选择指数移动平均线 (EMA) 而不是简单移动平均线? EMA 能更快地适应阅读速度的变化(例如用户阅读速度随时间提高或在简单/困难游戏之间切换),无需固定大小的缓冲区,并且使用单一的递归公式。 数据科学方面, α=0.2 的值意味着近期读数的权重约为 20%,而累积基线的权重约为 80%,这既保证了稳定性,又能对持续的速度变化做出响应。
批量计算变体:对于所有读数都可用的事后分析,首先使用简单的启发式方法过滤掉明显的异常值,然后根据剩余的“干净”读数计算 EMA 基线:
Python
def batch_detect_afk(readings: list[tuple[float, int]]) -> list[bool]: """ Batch AFK detection for after-the-fact analysis. readings: list of (time_seconds, char_count) tuples """ # First pass: rough filter using simple heuristic def rough_filter(time, chars): return time <= max(5, min(chars * 2.0, 180)) clean_readings = [(t, c) for t, c in readings if rough_filter(t, c) and c >= 2] if len(clean_readings) < 5: # Not enough clean data, use simple heuristic return [time > max(5, min(chars * 1.2, 120)) for time, chars in readings] # Compute baseline from clean readings time_per_char_values = [t / c for t, c in clean_readings] baseline = sum(time_per_char_values) / len(time_per_char_values) # Second pass: detect outliers results = [] for time, chars in readings: threshold = max(5, min(chars * baseline * 3.0, 180)) results.append(time > threshold) return results
算法 3:带中位数绝对偏差的修正 Z 分数
这种方法提供了统计上最严谨的异常值检测。与标准 Z 分数(假设数据服从正态分布且对异常值敏感)不同,修正 Z 分数全程使用中位数,使其对阅读时间典型的右偏分布具有鲁棒性。 数据科学
Python
from collections import deque import statistics class RobustAFKDetector: def __init__(self, window_size: int = 20): self.window_size = window_size self.time_per_char_history = deque(maxlen=window_size) # Modified Z-score threshold (Iglewicz & Hoaglin recommend 3.5) self.THRESHOLD = 3.5 self.K = 0.6745 # Scaling constant for MAD self.ABSOLUTE_MIN = 5 self.ABSOLUTE_MAX = 180 self.FALLBACK_TIME_PER_CHAR = 1.2 def record_reading(self, time_seconds: float, char_count: int) -> None: """Record a confirmed reading (not AFK).""" if char_count < 2: return time_per_char = max(0.1, min(time_seconds / char_count, 5.0)) self.time_per_char_history.append(time_per_char) def is_afk(self, time_seconds: float, char_count: int) -> bool: """Detect if current reading time is anomalous.""" if char_count < 1: return time_seconds > self.ABSOLUTE_MIN # Hard limit check if time_seconds > self.ABSOLUTE_MAX: return True # Need minimum samples for statistical detection if len(self.time_per_char_history) < 5: threshold = char_count * self.FALLBACK_TIME_PER_CHAR * 3 return time_seconds > max(self.ABSOLUTE_MIN, threshold) # Calculate MAD-based detection data = list(self.time_per_char_history) median = statistics.median(data) abs_deviations = [abs(x - median) for x in data] mad = statistics.median(abs_deviations) # Handle edge case: MAD = 0 (all values nearly identical) if mad < 0.01: mad = 0.1 # Modified Z-score: M = 0.6745 × (x - median) / MAD time_per_char = time_seconds / char_count modified_z = self.K * (time_per_char - median) / mad return modified_z > self.THRESHOLD
为什么使用修正 Z 分数?阅读时间分布呈右偏态——大多数阅读速度集中在正常速度附近,而 AFK 事件则呈长尾状,且越来越少见。使用均值和标准差的标准 Z 分数会被这些异常值拉扯,导致“掩蔽”效应,极端值会抬高基线,从而无法检测到中等异常值。而使用中位数和平均绝对偏差 (MAD) 的修正 Z 分数具有50% 的临界值,这意味着即使一半的数据是异常值,它仍然准确。
常数 0.6745使得修正后的 Z 分数与正态分布下的标准 Z 分数具有可比性(σ ≈ 1.4826 × MAD)。3.5的阈值是 Iglewicz 和 Hoaglin 在 1993 年关于稳健异常值检测的研究中提出的学术标准。
推荐的默认参数及其理由
| 范围 | 推荐值 | 理由 |
|---|---|---|
| 最小阈值 | 5秒 | 反应时间下限;能够处理点击极短的对话 |
| 最大阈值 | 120-180秒 | 超过这个时间就肯定是挂机了;2-3分钟已经算是很宽容了。 |
| 每字符秒数 | 1.2 启发式 | 可容纳 50 字符/分钟的读取器,并配备 3 倍处理缓冲区。 |
| EMA_ALPHA | 0.2 | 稳定性与响应性比例为 80/20 |
| 异常乘数 | 3.0 | 比基线值低约 3 个标准差 |
| 修改后的 Z 阈值 | 3.5 | 基于平均绝对偏差(MAD)的异常值检测的学术标准 |
| 热身示例 | 5-10 | 稳定基线估计的最低要求 |
| 窗口大小 | 20 | 滚动窗口捕捉近期的阅读模式 |
需要处理的关键边界情况
非常短的句子(「ああ…」「はい」「うん」):这些只有 2-5 个字符的句子通常需要 1-3 秒才能读完。5 秒的 MIN_THRESHOLD 提供了一个较为宽松的下限,同时又比 60 秒的固定计时器要好得多。建议将阅读时间低于预期时间 0.3 倍的句子标记为“跳过”,而不是标记为已读。
过长的段落(200 个字符以上):MAX_THRESHOLD 上限可防止出现不合理的阈值。即使是学习速度较慢的玩家,输入一个文本框也不应该超过 2-3 分钟。如果他们花费的时间过长,则可能是玩家挂机,或者游戏中的段落过长,需要分段处理。
对话选择:当游戏呈现多个选项时,用户会暂停游戏以考虑选择。如果可检测(多个文本选项、菜单状态),则将阈值乘以1.5-2 倍。
旁白语速:播放音频时,最小阅读时间等于音频持续时间——用户无法比语音播放速度更快。如果音频持续时间可用: threshold = max(audio_duration × 1.5, normal_threshold) 。
冷启动/新游戏:在预热阶段,由于自适应方法缺乏数据,简单的启发式方法可以提供合理的默认值。存储每个游戏的基线数据,以加快未来运行同一游戏的进程。
“过快”检测:点击时间显著低于预期(低于预期时间的 0.3 倍)表示用户未阅读就直接跳过。这与阅读统计的准确性相关,但与 AFK 检测无关。
Python
def classify_reading(time_seconds: float, char_count: int, baseline_per_char: float) -> str: expected = char_count * baseline_per_char ratio = time_seconds / expected if expected > 0 else 0 if ratio < 0.3: return "skipped" elif ratio > 3.0: return "afk" else: return "normal"
最终实施建议
首先使用算法 1 (字符启发式算法)。它只需要大约 10 行代码,无需任何配置,无需预热,并且能够正确处理大多数情况。公式max(5, min(chars × 1.2, 120))消除了固定计时器忽略文本长度的根本问题。
如果用户在使用一段时间后报告检测不准确,则添加算法 2 (EMA 自适应)。这需要在每局游戏中存储一个浮点基线值,并在每次有效读取后更新该基线值。预热期很短(5-10 个文本框),对于读取速度与默认速度差异较大的用户而言,准确率的提升非常显著。
仅当您观察到 EMA 存在系统性准确性问题时才考虑算法 3 (修正 Z 分数)——例如,用户经常出现多次离线(AFK)的会话,从而污染了基线数据。基于 MAD 的方法可以很好地处理这种情况,但会增加实现的复杂性,并且需要维护一个滚动历史读数窗口。
对于需求中指定的批量/事后计算,算法 2 的两遍批量检测变体是理想之选:首先使用简单的启发式方法识别干净读数,然后基于这些读数计算基线,最后将所有读数与该基线进行分类。这种方法既具备所有数据可用的稳健性,又兼具基于字符方法的简便性。
原文: https://skerritt.blog/automatic-afk-detection-for-visual-novel-reading-statistics/