问卷考试系统设计(四):质量控制——测谎题、Z3 组卷验证与异常检测
上一篇:问卷考试系统设计(三):评分引擎
问卷收上来一堆答案,怎么判断哪些是认真填的、哪些是随便糊弄的?另外,在出卷阶段,怎么保证组出来的试卷在分数分布上是合理的?这两个问题分别对应答题后质量检测和出卷时约束验证。
三道防线:测谎题检测被试是否在刻意美化,Z3 约束求解器在组卷阶段验证分数区间无冲突,异常模式检测抓全选、极端值、过快作答等刷卷行为。
质量控制分成卷前验证和卷后检测,两条链路末尾汇总成答卷可信度:
第一道防线:测谎题,检测"装好人"
设计原理
测谎题的逻辑很简单:大多数人不太可能在所有测谎题上都选“完美”的那一端。
MMPI 的 L(Lie)效度量表是经典案例。它会放一些社会称许性很强、但现实中很难完全做到的题目,例如:
- “我从来没有说过谎”,大多数人都说过谎,全选“是”反而可疑
- “我从不背后说人坏话”,太完美了,不真实
- “我从来不会迟到”,现实里很难完全做到
这些题目不计入心理维度的分数,唯一的用途就是检测被试的作答态度。
评分逻辑
每道测谎题都有一个或多个"可疑答案"。被试选中了可疑答案,就记 1 分;否则记 0 分。末尾把所有测谎题的可疑分加起来,和阈值做比较。
阈值不要写死,更稳妥按量表手册和人群配置。下面只是工程示例:
- VALID(有效):可疑比例 ≤ 50%
- SUSPICIOUS(可疑):50% < 可疑比例 ≤ 80%
- INVALID(无效):可疑比例 > 80%
实现
public LieScaleResult calculate(List<Question> lieScaleQuestions,
Map<String, Answer> answers) {
int totalLieScore = 0;
for (Question q : lieScaleQuestions) {
Answer answer = answers.get(q.getId());
if (answer == null) continue;
int value = answer.getSelectedValue();
if (q.getLieAnswerValues().contains(value)) {
totalLieScore++;
}
}
double lieRatio = (double) totalLieScore / lieScaleQuestions.size();
String verdict;
if (lieRatio > 0.8) verdict = "INVALID";
else if (lieRatio > 0.5) verdict = "SUSPICIOUS";
else verdict = "VALID";
return new LieScaleResult(totalLieScore, lieScaleQuestions.size(),
lieRatio, verdict);
}注意:测谎题的可疑答案不一定总是更高分。有些题是"选低分才可疑",有些还可能有多个可疑选项。稳妥做法是在题目配置里显式声明
lieAnswerValues。
第二道防线:Z3 约束求解,组卷阶段的分数验证
Z3 是什么
Z3 是微软开发的 SMT(Satisfiability Modulo Theories)约束求解器,能判断一组约束条件是否可满足,并给出满足条件的具体解。在问卷系统中,Z3 主要用于出卷/组卷阶段,验证试卷的分数配置是否合理,而不是用来检测答卷质量。
常见误解:很多人以为 Z3 在问卷系统里是用来"检测答题一致性"的。实际上,Z3 的核心价值在组卷阶段,它是一个约束求解工具,用来验证"这样组出来的卷子,分数分布会不会有问题"。
组卷时需要验证什么
一份问卷或考试的试卷,题目组合在一起后,有几个关键约束需要满足:
1. 分数区间无冲突
一份考试试卷有 10 道题,每题 10 分,总分 100 分。但如果其中有一道题的满分配置成了 20 分(和别的题冲突了),总分就变成了 110 分。这种冲突在手动配置时很容易发生。
更复杂的场景:一份心理量表有 5 个维度,每个维度的原始分范围不同。组卷时需要验证:各维度的分数范围是否合理,会不会出现某个维度因为题目太少导致分数区分度不够(比如只有 2 道题,每题 1-5 分,总分只能是 2-10 分,8 个可能的值,区分度很差)。
2. 无孤立维度
如果一个维度只关联了 1 道题,那这个维度的分数完全由这一道题决定,没有统计意义。Z3 可以检查:每个维度是否至少有 N 道题(通常 N ≥ 3)。
3. 选项分布合理性
如果一份量表的 20 道题全是正向题,没有反向题,那被试很容易全选同一个选项来应付。Z3 可以验证:正向题和反向题的比例是否合理(通常反向题占比 20%-30%)。
4. 测谎题数量合理
测谎题太少(1-2 道)检测效果差,太多又增加问卷长度。Z3 可以约束测谎题的数量范围。
用 Z3 做组卷验证
以下是用 Z3 Java API 验证试卷配置的示例:
import com.microsoft.z3.*;
public class PaperConstraintVerifier {
public VerificationResult verify(PaperConfig config) {
Context ctx = new Context();
Solver solver = ctx.mkSolver();
// 为每个维度创建变量,并绑定到当前试卷的实际题目数
Map<String, IntExpr> dimCounts = new HashMap<>();
for (String dim : config.getDimensions()) {
IntExpr count = ctx.mkIntConst("dim_" + dim);
dimCounts.put(dim, count);
solver.add(ctx.mkEq(count, ctx.mkInt(config.countQuestionsOfDimension(dim))));
}
IntExpr totalQuestions = ctx.mkInt(config.getTotalQuestionCount());
IntExpr reverseCount = ctx.mkInt(config.getReverseQuestionCount());
IntExpr lieCount = ctx.mkInt(config.getLieQuestionCount());
// 约束1: 每个维度至少 3 道题(无孤立维度)
for (IntExpr dimCount : dimCounts.values()) {
solver.add(ctx.mkGe(dimCount, ctx.mkInt(3)));
}
// 约束2: 总题数在合理范围
solver.add(ctx.mkGe(totalQuestions, ctx.mkInt(config.getMinQuestions())));
solver.add(ctx.mkLe(totalQuestions, ctx.mkInt(config.getMaxQuestions())));
// 约束3: 反向题占比 20%-30%
solver.add(ctx.mkGe(reverseCount,
ctx.mkDiv(ctx.mkMul(totalQuestions, ctx.mkInt(20)), ctx.mkInt(100))));
solver.add(ctx.mkLe(reverseCount,
ctx.mkDiv(ctx.mkMul(totalQuestions, ctx.mkInt(30)), ctx.mkInt(100))));
// 约束4: 测谎题至少 4 道,不超过总题数的 15%
solver.add(ctx.mkGe(lieCount, ctx.mkInt(4)));
solver.add(ctx.mkLe(lieCount,
ctx.mkDiv(ctx.mkMul(totalQuestions, ctx.mkInt(15)), ctx.mkInt(100))));
// 约束5: 各维度题目数之和 = 总题数
solver.add(ctx.mkEq(totalQuestions,
dimCounts.values().stream()
.reduce(ctx.mkInt(0), ctx::mkAdd)));
// 检查是否可满足
Status status = solver.check();
VerificationResult result = new VerificationResult();
if (status == Status.SATISFIABLE) {
Model model = solver.getModel();
result.setSatisfiable(true);
result.setModel(extractModel(model, dimCounts, totalQuestions,
reverseCount, lieCount));
} else {
result.setSatisfiable(false);
result.setReason("约束不可满足:试卷配置存在冲突,请检查各维度题数、"
+ "反向题比例和测谎题数量的约束条件");
}
ctx.close();
return result;
}
}这里有个关键点:变量要绑定到当前试卷的实际配置。否则 Z3 只会告诉你“理论上存在一份满足条件的卷子”,不能证明眼前这份卷子没问题。
分数区间可达性检测
更具体的场景:验证每个维度的原始分范围是否足够宽,且配置的等级切点落在可达到的分数区间内。不同维度的 T 分范围相同并不一定是错误,真正要防的是"分数永远到不了某个等级"。
public class ScoreRangeVerifier {
public RangeVerificationResult verifyRanges(List<DimensionConfig> dimensions) {
Context ctx = new Context();
Solver solver = ctx.mkSolver();
for (DimensionConfig dim : dimensions) {
int min = dim.getQuestionCount() * dim.getMinPerQuestion();
int max = dim.getQuestionCount() * dim.getMaxPerQuestion();
// 分数范围至少能区分 4 个等级
solver.add(ctx.mkGe(ctx.mkInt(max - min), ctx.mkInt(3)));
// 每个等级切点必须落在该维度可达到的原始分范围内
for (int cutoff : dim.getLevelCutoffs()) {
solver.add(ctx.mkGe(ctx.mkInt(cutoff), ctx.mkInt(min)));
solver.add(ctx.mkLe(ctx.mkInt(cutoff), ctx.mkInt(max)));
}
}
Status status = solver.check();
RangeVerificationResult result = new RangeVerificationResult();
result.setSatisfiable(status == Status.SATISFIABLE);
if (status != Status.SATISFIABLE) {
result.setReason("分数区间配置不合理:题目数、单题分值或等级切点存在冲突");
}
return result;
}
}Z3 在问卷系统中的定位
| 应用阶段 | Z3 的作用 | 具体场景 |
|---|---|---|
| 出卷阶段 | 约束验证 | 验证题数、维度覆盖、反向题比例、测谎题数量 |
| 出卷阶段 | 分数区间验证 | 确保各维度分数范围、等级切点可达 |
| 出卷阶段 | 选项分布验证 | 确保选项数量和分布合理 |
Z3 在问卷系统中核心的应用是出卷阶段的约束验证,在试卷生成时就发现配置问题,而不是等到用户答完题才发现分数算不出来。这是一种"预防"而非"检测"的思路。
第三道防线:异常模式检测
Z3 检测的是组卷配置的合理性,模式检测则从作答行为入手,抓那些一眼就能看出在刷卷的行为。
全选同一选项(Straight-Lining)
更粗暴的刷卷方式:从头到尾选同一个选项。
Map<Integer, Long> distribution = answers.values().stream()
.filter(a -> !a.isBlank())
.collect(Collectors.groupingBy(Answer::getSelectedValue, Collectors.counting()));
long totalAnswered = answers.values().stream()
.filter(a -> !a.isBlank()).count();
Optional<Map.Entry<Integer, Long>> maxEntry =
distribution.entrySet().stream().max(Map.Entry.comparingByValue());
double maxRatio = (double) maxEntry.get().getValue() / totalAnswered;
boolean detected = maxRatio > 0.9 && totalAnswered >= 10;90% 阈值比较保守,实际中也可以根据量表特点调整。
全选极端值
只选"非常不同意"或"非常同意",不碰中间选项:
long extremeCount = answers.entrySet().stream()
.filter(e -> !e.getValue().isBlank())
.filter(e -> {
Question q = findQuestion(e.getKey());
int value = e.getValue().getSelectedValue();
return value == 1 || value == q.getMaxOptionValue();
})
.count();
double extremeRatio = (double) extremeCount / totalAnswered;
boolean detected = extremeRatio > 0.8;作答时间过短
平均每题作答时间不到 2 秒,大概率没有认真看题。不过阈值要结合题长,单字确认题和长题干量表不能用同一套标准:
long totalTimeMs = session.getEndTime().toEpochMilli()
- session.getStartTime().toEpochMilli();
double avgTimePerQuestionMs = (double) totalTimeMs / questionCount;
// 分级判定
String level;
if (avgTimePerQuestionMs < 1000) level = "CRITICAL"; // 脚本刷的
else if (avgTimePerQuestionMs < 2000) level = "WARNING"; // 大概率没看
else level = "NORMAL";异常均匀的作答时间
如果每道题的作答时间几乎一模一样(比如每题都是 3 秒),那大概率不是人类在答题。用变异系数(CV)检测:
double mean = durations.stream().mapToLong(Long::longValue).average().orElse(0);
double variance = durations.stream()
.mapToDouble(d -> Math.pow(d - mean, 2))
.average().orElse(0);
double stdDev = Math.sqrt(variance);
double cv = mean > 0 ? stdDev / mean : 0;
// CV < 0.1 且平均耗时 > 500ms,说明每题耗时几乎一样
boolean detected = cv < 0.1 && mean > 500;综合判定:三道防线交叉验证
三道防线各有侧重,单独用都有盲区。需要组合使用:
List<String> reasons = new ArrayList<>();
if ("INVALID".equals(lieScale.getVerdict())) {
reasons.add("测谎分过高");
}
if (!consistency.isReliable()) {
reasons.add("配对题一致性过低");
}
if (pattern.getStraightLining().isDetected()) {
reasons.add("直线作答");
}
if (pattern.getSpeeding().isDetected()) {
reasons.add("作答过快");
}
if (pattern.getExtremePatterning().isDetected()) {
reasons.add("极端值作答");
}
String grade;
if (reasons.isEmpty()) grade = "A"; // 完全有效
else if (reasons.size() == 1) grade = "B"; // 轻度可疑
else if (reasons.size() == 2) grade = "C"; // 中度可疑
else grade = "D"; // 严重可疑为什么 2 个标记就降级?单一异常可能有合理解释,测谎题得分高可能只是被试确实道德标准比较高。但两个独立的异常同时出现,巧合的概率就很小了。
实际踩过的几个坑
测谎题阈值要按人群调。 青少年群体的测谎分普遍偏高,因为他们确实更容易"希望自己是好的"。用成人的阈值去判青少年,误判率会很高。在配置里加了 ageGroup 字段,不同年龄段用不同阈值。
时间检测要考虑移动端场景。 手机上填问卷,中途接个电话、回个消息都很正常。单纯看总时间会误判。改成检测"连续快速作答"的片段,如果有 5 题以上连续每题不到 1 秒,才标记为过快。
不要只标记不解释。 每次检测都记录具体原因和数值,“测谎分 7/8"“配对题一致性 42.8%““平均每题 1.3 秒”,方便后续复查和调参。
小结
质量控制的三道防线:
- 测谎题:通过"不可能全对"的题目检测美化倾向
- Z3 约束求解:在组卷阶段验证分数区间无冲突、无孤立维度、选项分布合理
- 异常模式检测:直线作答、极端值、过快作答、异常均匀时间
Z3 的核心价值在预防,出卷时就发现配置问题,而不是事后检测。异常模式检测的核心在发现,从作答行为中识别刷卷。两者互补,一个管"卷子合不合理”,一个管"答题认不认真”。
下一篇聊前端,渲染引擎、状态管理、防作弊和移动端适配。