← 返回

问卷考试系统设计(四):质量控制——测谎题、Z3 组卷验证与异常检测

上一篇:问卷考试系统设计(三):评分引擎

问卷收上来一堆答案,怎么判断哪些是认真填的、哪些是随便糊弄的?另外,在出卷阶段,怎么保证组出来的试卷在分数分布上是合理的?这两个问题分别对应答题后质量检测出卷时约束验证

三道防线:测谎题检测被试是否在刻意美化,Z3 约束求解器在组卷阶段验证分数区间无冲突,异常模式检测抓全选、极端值、过快作答等刷卷行为。

质量控制分成卷前验证和卷后检测,两条链路末尾汇总成答卷可信度:

flowchart TB A["发布前"] --> B["Z3 约束验证"] B --> C["题量、维度、互斥规则检查"] C --> D{"可发布"} E["提交后"] --> F["测谎题检测"] E --> G["一致性检测"] E --> H["作答模式检测"] F --> I["质量分"] G --> I H --> I D --> J["问卷版本可信"] I --> K["答卷有效性等级"]

第一道防线:测谎题,检测"装好人"

设计原理

测谎题的逻辑很简单:大多数人不太可能在所有测谎题上都选“完美”的那一端

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 的核心价值在预防,出卷时就发现配置问题,而不是事后检测。异常模式检测的核心在发现,从作答行为中识别刷卷。两者互补,一个管"卷子合不合理”,一个管"答题认不认真”。

下一篇聊前端,渲染引擎、状态管理、防作弊和移动端适配。

上一篇:问卷考试系统设计(三):评分引擎 下一篇:问卷考试系统设计(五):前端引擎,渲染、状态管理与防作弊