← 返回

问卷考试系统设计(三):评分引擎--维度计分、公式 DSL 与常模

上一篇:问卷考试系统设计(二):题目 DSL 设计

评分引擎是整个系统里容易出错、也需要优先可审计的部分。心理量表和普通考试上限的差异也在这里:不能只把考试系统那套 sum() 逻辑搬过来,很多规则要单独设计。


心理量表评分 vs 普通考试

两者的差异很大。

普通考试的计分模型很直白:答对得 1 分,答错得 0 分,加起来就是总分。题目按章节组织,分数按章节汇总,出个柱状图就完事了。

心理量表这里,逻辑就不一样了。题目常用 Likert 等级量表–“从不”到“总是”、“非常不同意”到“非常同意”,每个选项对应一个分值,没有对错之分,只有程度差异。量表按心理学维度组织,比如焦虑、抑郁、躯体化,每道题在设计阶段就归属到某个维度了。

更关键的差异:

  • 反向计分题:防止被试不动脑子全选同一个选项。正常题"我觉得工作很有成就感"选"总是"得 5 分,反向题"我觉得工作毫无意义"选"总是"得 1 分。考试没有这种概念。
  • 测谎题:检测被试是否在刻意美化自己。考试也不需要这个。
  • 维度分析:心理量表的核心价值是维度分–焦虑多少分、抑郁多少分、躯体化多少分,分别对应什么严重程度。光给一个总分没有意义。
  • 常模参照:原始分需要转换成标准分(T 分/Z 分),然后和常模比较才能得出有意义的结论。这是心理量表和考试上限的区别,后面会详细讲。
维度普通考试心理量表
计分逻辑对/错二元程度量表(1-5 Likert)
反向题不存在存在,需要反转分数
维度结构章节/知识点心理学维度(如焦虑、抑郁)
质量检测测谎题、一致性检测
结果呈现总分维度分 + T分/Z分 + 常模比较

维度模型设计

维度和题目不要做成简单多对多,还要记录反向题、测谎题和父子维度聚合规则:

flowchart TB Scale["量表版本"] --> Root["总维度"] Root --> D1["一级维度 A"] Root --> D2["一级维度 B"] D1 --> L1["叶子维度 A1"] D1 --> L2["叶子维度 A2"] L1 --> Q1["题目 1"] L1 --> Q2["题目 2<br/>反向题"] L2 --> Q3["题目 3"] D2 --> L3["叶子维度 B1"] L3 --> Q4["测谎题"] Q1 --> Sum["叶子维度聚合"] Q2 --> Sum Q3 --> Sum Sum --> Parent["父维度聚合"]

维度与题目的关联

每个量表由若干维度组成,每道题归属于一个维度。维度之间可以有层级关系–比如职业兴趣测评里先有"人际/事务/研究"这类一级维度,下面再拆更细的子维度。类似 SCL-90 这种经典量表则是相对扁平的 9 个因子,不要硬套层级。

public class Dimension {
    private String id;           // 维度ID,如 "anxiety"
    private String name;         // 维度名称,如 "焦虑"
    private String parentId;     // 父维度ID,null表示顶层
    private WeightStrategy weightStrategy; // 加权策略
}

public enum WeightStrategy {
    SUM,       // 简单求和,最常用
    AVERAGE,   // 平均值,题目数不等时用
    WEIGHTED   // 加权求和
}

维度分计算

public class DimensionScoreCalculator {

    public DimensionScoreResult calculate(String dimensionId,
                                          List<QuestionScore> questionScores,
                                          Dimension dimension) {
        List<QuestionScore> dimScores = questionScores.stream()
            .filter(q -> q.getDimensionId().equals(dimensionId))
            .collect(Collectors.toList());

        if (dimScores.isEmpty()) {
            return DimensionScoreResult.empty(dimensionId);
        }

        double rawScore;
        switch (dimension.getWeightStrategy()) {
            case SUM:
                rawScore = dimScores.stream()
                    .mapToDouble(QuestionScore::getScore).sum();
                break;
            case AVERAGE:
                rawScore = dimScores.stream()
                    .mapToDouble(QuestionScore::getScore)
                    .average().orElse(0.0);
                break;
            case WEIGHTED:
                rawScore = dimScores.stream()
                    .mapToDouble(q -> q.getScore() * q.getWeight()).sum();
                break;
            default:
                rawScore = dimScores.stream()
                    .mapToDouble(QuestionScore::getScore).sum();
        }

        DimensionScoreResult result = new DimensionScoreResult();
        result.setDimensionId(dimensionId);
        result.setRawScore(rawScore);
        result.setQuestionCount(dimScores.size());
        return result;
    }
}

层级维度的递归计算

有层级结构的量表,先算所有叶子维度,再自底向上聚合:

public DimensionScoreResult calculateHierarchical(
        String dimensionId,
        Map<String, DimensionScoreResult> allScores,
        List<Dimension> allDimensions) {

    List<Dimension> children = allDimensions.stream()
        .filter(d -> dimensionId.equals(d.getParentId()))
        .collect(Collectors.toList());

    if (children.isEmpty()) {
        return allScores.get(dimensionId);  // 叶子节点
    }

    double parentScore = 0;
    int totalQuestions = 0;
    for (Dimension child : children) {
        DimensionScoreResult childResult =
            calculateHierarchical(child.getId(), allScores, allDimensions);
        parentScore += childResult.getRawScore();
        totalQuestions += childResult.getQuestionCount();
    }

    DimensionScoreResult result = new DimensionScoreResult();
    result.setDimensionId(dimensionId);
    result.setRawScore(parentScore);
    result.setQuestionCount(totalQuestions);
    return result;
}

反向计分

为什么需要反向题

心理量表有一个老大难问题:被试不动脑子,从头到尾全选"同意"或者全选"非常不同意"。这种直线作答会让结果完全失真。

反向题就是专门用来对付这种情况的。它在量表里随机穿插一些措辞和整体方向相反的题目。如果被试认真作答,反向题的得分应该和正向题呈现出合理的反向关系;如果全选一样,反向题就会暴露出来。

评分引擎实现

public class QuestionScoreCalculator {

    public QuestionScore calculate(Question question, Answer answer) {
        if (answer == null || answer.isBlank()) {
            return QuestionScore.unanswered(question.getId());
        }

        int rawValue = answer.getSelectedValue();
        int minOption = question.getMinOptionValue();
        int maxOption = question.getMaxOptionValue();

        // 反向计分:score = minValue + maxValue - rawValue
        // 1-5 量表就是 6 - rawValue,0-3 量表就是 3 - rawValue
        int score;
        if (question.isReverse()) {
            score = minOption + maxOption - rawValue;
        } else {
            score = rawValue;
        }

        QuestionScore qs = new QuestionScore();
        qs.setQuestionId(question.getId());
        qs.setDimensionId(question.getDimensionId());
        qs.setRawValue(rawValue);     // 保留原始值
        qs.setScore(score);           // 计分后的值
        qs.setReversed(question.isReverse());
        return qs;
    }
}

这里同时保留了 rawValue(原始选项值)和 score(计分后的值)。后续做数据分析、导出原始数据或者排查问题的时候,原始值不能丢。

一个容易踩的坑

反向计分的反转操作发生在单题计分阶段,不是维度汇总阶段。QuestionScore 里存的已经是反转后的分数了,维度计算的时候直接求和就行,不用再判断哪些题是反向的。

单点反转、下游无感–这是反向计分的设计原则。


计分公式 DSL

原始分上限的问题是没有参照系。一个焦虑维度原始分 28 分,到底算高还是算低?单看这个数字判断不了,因为你不知道同类人群的 28 分处在什么位置。

不同量表的转换方式各不相同。如果把转换逻辑硬编码在评分代码里,每新增一个量表就要改一次代码。所以我把这部分做成可配置的计分公式引擎。

公式 DSL 设计

DSL 只描述转换规则,评分引擎负责解释执行:

scoring_formulas:
  sds_standard:
    type: linear
    formula: "ceil(raw * 1.25)"
    description: "SDS标准分 = 原始分 × 1.25 取整"

  anxiety_t_score:
    type: t_score
    formula: "50 + 10 * (raw - 35) / 8"
    description: "焦虑T分,常模M=35, SD=8"

formula 就是一段数学表达式,支持四则运算和 ceil()round() 等函数。生产环境可以用 SpEL、MVEL 这类成熟表达式引擎,但一定要做白名单:只开放数学函数和必要变量,禁掉类型访问、反射和任意方法调用。


常模:到底是什么

这是整个系列里容易被误解的概念,值得专门用一节来讲清楚。

常模不是"参考标准"

很多人把"常模"理解成一个固定的"标准答案"或者"合格线"–这是错的。

常模(Norm)是心理测量学中的一个技术概念,指特定群体在某测验上的分数分布特征,通常包括均值(M)、标准差(SD)、百分位数等统计量。它的作用是将个体分数与群体分数进行比较,得出该个体在群体中的相对位置

举个例子。一份焦虑量表,原始分 28 分。这个数字本身没有意义,你不知道 28 分算高还是低。但如果告诉你:“中国成年男性的焦虑常模是均值 35,标准差 8”,你就能算出 T 分大约 41,低于平均值 50,说明这个人的焦虑水平低于同龄男性的平均水平。这才是常模的价值,它提供了一个参照系,让原始分变得可解释

常模的几个关键特征

  1. 常模是群体特异的。同一份量表,不同群体的常模不一样。18-25 岁大学生的抑郁常模和 40-60 岁中年人的常模肯定不同。所以常模必须标注它所代表的群体–“中国成年男性”、“北京市高中生”、“IT 从业者”。

  2. 常模需要定期更新。社会在变化,人们的心理状态也在变化。2010 年的常模到 2025 年可能已经不适用了。所以常模通常会标注建立时间和样本量。

  3. 常模不是诊断标准。常模告诉你的是"你在这个群体里排第几",不是"你有没有病"。T 分 65 意味着你高于 93% 的同龄人,但这不等于你"有焦虑症"–诊断需要临床访谈和专业判断。

  4. 常模的建立需要大样本。一个靠谱的常模通常需要几百到几千人的代表性样本。样本量太小、抽样偏差太大,建立出来的常模没有参考价值。

有了常模,就可以做标准分转换。更常用的是 T 分,均值 50,标准差 10。T 分 50 就是“平均水平”,60 以上开始偏高。比原始分直观得多,因为你知道它在群体里大概是什么位置。

分级参考(以焦虑为例):

T 分范围等级含义
< 55正常焦虑水平在正常范围内
55-60轻度存在轻度焦虑倾向
60-70中度中度焦虑,建议关注
> 70重度重度焦虑,建议专业干预

这些分界值不是随意定的,是根据常模分布和临床验证确定的。不同的量表分界值可能不同,不能混用。

在系统中如何使用常模

在我们的系统里,常模作为配置数据存储:

{
  "questionnaire_code": "SAS",
  "norms": [
    {
      "name": "中国成人常模(2020)",
      "population": "18-65岁中国成人",
      "sample_size": 3200,
      "established": "2020",
      "dimensions": {
        "anxiety": { "mean": 29.78, "sd": 10.07 },
        "total": { "mean": 33.80, "sd": 12.54 }
      }
    },
    {
      "name": "大学生常模(2022)",
      "population": "18-25岁中国大学生",
      "sample_size": 1800,
      "established": "2022",
      "dimensions": {
        "anxiety": { "mean": 31.20, "sd": 9.85 },
        "total": { "mean": 35.60, "sd": 11.90 }
      }
    }
  ]
}

评分引擎根据用户的人口学信息、量表版本和任务快照匹配适用常模,再计算 T 分和等级。找不到适用人群时,默认不生成基于常模的等级解释;业务规则若允许使用覆盖范围更广的参照组,报告必须展示参照组名称、采样时间和适用性限制。

这个设计让常模变成了可配置的数据–新增一套常模只需要加一段 JSON,评分引擎不用改代码。


完整评分流程

评分流水线要把分数计算和质量检测都落库,报告只读取已确认的评分结果:

flowchart LR A["答卷提交"] --> B["单题计分"] B --> C["维度聚合"] C --> D["质量检测"] D --> E["常模匹配"] E --> F["标准分转换"] F --> G["结果落库"] G --> H["触发报告任务"] D --> I{"答卷有效性"} I -- "低可信" --> J["标记复核"] I -- "可信" --> E

把所有模块串起来,一份答卷从提交到出分大致要经历 5 个阶段:

public ScoreResult score(Scale scale, AnswerSession session) {
    List<Question> questions = scale.getQuestions();
    Map<String, Answer> answers = session.getAnswersMap();

    // Step 1: 单题计分(含反向题处理)
    List<QuestionScore> questionScores = questions.stream()
        .map(q -> questionCalc.calculate(q, answers.get(q.getId())))
        .collect(Collectors.toList());

    // Step 2: 维度分计算(先叶子后父节点)
    Map<String, DimensionScoreResult> dimensionScores = new LinkedHashMap<>();
    for (Dimension dim : scale.getDimensions()) {
        boolean isLeaf = scale.getDimensions().stream()
            .noneMatch(d -> dim.getId().equals(d.getParentId()));
        if (isLeaf) {
            dimensionScores.put(dim.getId(),
                dimensionCalc.calculate(dim.getId(), questionScores, dim));
        }
    }
    for (Dimension dim : scale.getDimensions()) {
        if (dim.getParentId() == null) {
            dimensionScores.put(dim.getId(),
                hierarchicalCalc.calculateHierarchical(
                    dim.getId(), dimensionScores, scale.getDimensions()));
        }
    }

    // Step 3: 质量检测(测谎题、配对题一致性、模式检测)
    LieScaleResult lieResult = lieCalc.calculate(...);
    ConsistencyResult consistencyResult = consistencyChecker.checkPairedItems(...);
    PatternDetectionResult patternResult = patternDetector.detect(...);
    AnswerValidity validity = patternDetector.assessValidity(...);

    // Step 4: 标准分转换(使用常模)
    Map<String, StandardScoreResult> standardScores = new LinkedHashMap<>();
    Norm norm = normService.selectNorm(session.getUserId(), scale);
    for (Map.Entry<String, DimensionScoreResult> entry : dimensionScores.entrySet()) {
        double raw = entry.getValue().getRawScore();
        DimensionNorm dimNorm = norm.getDimensionNorm(entry.getKey());
        if (dimNorm != null) {
            double zScore = (raw - dimNorm.getMean()) / dimNorm.getSd();
            double tScore = 50 + 10 * zScore;
            standardScores.put(entry.getKey(),
                StandardScoreResult.of(raw, zScore, tScore, classifyTScore(tScore)));
        }
    }

    // Step 5: 组装最终结果
    ScoreResult result = new ScoreResult();
    result.setQuestionScores(questionScores);
    result.setDimensionScores(dimensionScores);
    result.setStandardScores(standardScores);
    result.setLieScaleResult(lieResult);
    result.setConsistencyResult(consistencyResult);
    result.setPatternResult(patternResult);
    result.setValidity(validity);
    return result;
}

调用示例输出:

=== 评分结果 ===
答卷ID: ans_20260522_001
用户: user_12345
常模: 中国成人常模(2020)

--- 维度分数 ---
  anxiety:    原始分=28.0  → T=41.3(正常)
  depression: 原始分=35.0  → T=54.7(轻度)
  somatization: 原始分=18.0 → T=48.8(正常)

--- 答卷质量 ---
  有效性: A
  测谎: VALID
  配对题一致性: 85.7%

心理问卷 vs 普通问卷的评分差异

末尾总结一下两种问卷在评分引擎层面的处理差异:

处理步骤心理问卷普通问卷
单题计分Likert 分值映射 + 反向题对/错判定 或 不计分
维度分按心理学维度聚合按章节/知识点聚合
常模参照需要,T 分/Z 分转换不需要
质量检测测谎题 + 一致性 + 模式检测通常较少
等级判定基于常模分布基于及格线

在代码层面,我们用 questionnaire.category 字段区分类型,评分引擎根据类型选择不同的处理分支。普通问卷跳过常模转换和质量检测步骤,直接输出原始分和及格/不及格判定。


小结

评分引擎是心理量表系统核心的部分。几个关键设计决策:

  1. 维度模型是基础–支持树形层级、多种聚合策略
  2. 反向计分是标配–单点反转,下游无感
  3. 公式 DSL 化–评分规则用数据描述,不硬编码
  4. 常模是参照系–并非"标准答案",,重点是群体分数分布,用于将个体分数放到群体中定位
  5. 心理问卷和普通问卷要区分处理–评分流程、质量检测、结果呈现都有差异

下一篇聊质量控制–测谎题、Z3 组卷验证、异常作答检测。

上一篇:问卷考试系统设计(二):题目 DSL 设计 下一篇:问卷考试系统设计(四):质量控制–测谎题、Z3 组卷验证与异常检测