← 返回

问卷考试系统设计(七):数据统计、可视化与系列总结

上一篇:问卷考试系统设计(六):报告生成

数据采集只是起点。问卷系统真正产生价值的时刻,是数据沉淀之后,研究者打开统计面板,看到分组差异、趋势变化和数据质量。这篇聚焦数据统计分析、多格式导出、隐私脱敏、大屏可视化,以及常模的建立过程。

统计分析要区分离线口径和实时看板,避免把探索性分析塞进在线请求:

flowchart LR A["答卷清洗"] --> B["维度分汇总"] B --> C["描述统计"] C --> D["显著性检验"] D --> E["常模构建"] E --> F["离线导出"] B --> G["实时指标聚合"] G --> H["WebSocket 推送"] H --> I["大屏看板"] E --> J["常模版本"] J --> K["评分引擎读取"]

一、描述性统计

拿到一批量表分数,第一件事是看整体面貌:

public DescriptiveStats descriptive(List<Double> scores) {
    DoubleSummaryStatistics stats = scores.stream()
        .mapToDouble(Double::doubleValue)
        .summaryStatistics();

    double mean = stats.getAverage();
    double std = Math.sqrt(
        scores.stream()
            .mapToDouble(v -> Math.pow(v - mean, 2))
            .average().orElse(0.0)
    );
    List<Double> sorted = scores.stream().sorted().collect(Collectors.toList());
    double median = sorted.size() % 2 == 0
        ? (sorted.get(sorted.size() / 2 - 1) + sorted.get(sorted.size() / 2)) / 2.0
        : sorted.get(sorted.size() / 2);

    return new DescriptiveStats(mean, std, median, stats.getMin(), stats.getMax(),
        stats.getCount());
}

均值、标准差、中位数、极值,这几个数字能快速告诉研究者数据有没有异常、分布偏不偏。对于问卷系统这个体量的数据(通常几万条以内),纯 Java Stream 基本够用。

如果这些数据要用于常模估计,标准差建议用样本标准差(n-1);这里代码为了说明主流程做了简化。


二、分组统计与 T 检验

独立样本 T 检验

常见的对比:“男性和女性的焦虑水平是否存在显著差异”。用 Welch’s T 检验,不假设两组方差相等:

public TTestResult independentTTest(List<Double> group1, List<Double> group2) {
    double n1 = group1.size(), n2 = group2.size();
    double mean1 = group1.stream().mapToDouble(d -> d).average().orElse(0);
    double mean2 = group2.stream().mapToDouble(d -> d).average().orElse(0);

    double var1 = group1.stream().mapToDouble(d -> Math.pow(d - mean1, 2)).sum() / (n1 - 1);
    double var2 = group2.stream().mapToDouble(d -> Math.pow(d - mean2, 2)).sum() / (n2 - 1);

    double se = Math.sqrt(var1 / n1 + var2 / n2);
    double tStat = (mean1 - mean2) / se;

    // Welch-Satterthwaite 近似自由度
    double df = Math.pow(var1 / n1 + var2 / n2, 2) /
        (Math.pow(var1 / n1, 2) / (n1 - 1) + Math.pow(var2 / n2, 2) / (n2 - 1));

    TDistribution dist = new TDistribution(df);
    double pValue = 2.0 * (1.0 - dist.cumulativeProbability(Math.abs(tStat)));

    return new TTestResult(mean1, mean2, tStat, df, pValue);
}

p 值计算依赖 Apache Commons Math 的 TDistribution。p < 0.05 通常认为差异显著,但别只看 p 值;报告里更稳妥同时给出均值差、置信区间和效应量(比如 Cohen’s d)。

单因素方差分析(ANOVA)

当对比维度超过两组(比如按学历分组比较本科、硕士、博士),T 检验不够用,需要 ANOVA:

public AnovaResult oneWayAnova(Map<String, List<Double>> groups) {
    List<Double> all = groups.values().stream()
        .flatMap(Collection::stream).collect(Collectors.toList());
    double grandMean = all.stream().mapToDouble(d -> d).average().orElse(0);

    double ssb = groups.entrySet().stream()
        .mapToDouble(e -> {
            double groupMean = e.getValue().stream().mapToDouble(d -> d).average().orElse(0);
            return e.getValue().size() * Math.pow(groupMean - grandMean, 2);
        }).sum();

    double ssw = groups.entrySet().stream()
        .mapToDouble(e -> {
            double groupMean = e.getValue().stream().mapToDouble(d -> d).average().orElse(0);
            return e.getValue().stream().mapToDouble(d -> Math.pow(d - groupMean, 2)).sum();
        }).sum();

    int k = groups.size(), n = all.size();
    double fStat = (ssb / (k - 1)) / (ssw / (n - k));

    FDistribution fDist = new FDistribution(k - 1, n - k);
    double pValue = 1.0 - fDist.cumulativeProbability(fStat);

    return new AnovaResult(fStat, k - 1, n - k, pValue);
}

ANOVA 显著之后,还需要做事后多重比较(Tukey HSD)才能知道具体是哪两组之间有差异。


三、常模的建立过程

前面的文章提到了常模的概念,特定群体在某测验上的分数分布。这里讲一下常模是怎么建立的。

建立常模的步骤

第一步:确定目标群体。 常模必须明确它所代表的群体,“18-25 岁中国大学生”、“40-60 岁 IT 从业者”。群体定义越精确,常模的参考价值越高。

第二步:抽样采集。 需要一个有代表性的样本。样本量通常要求 300-500 人以上,大规模常模可能需要几千人。抽样要覆盖目标群体的各个子群体(不同地区、不同学校等),避免偏差。

第三步:数据清洗。 剔除无效问卷(测谎题不通过、直线作答、作答时间过短等)。这一步的质量直接决定常模的可靠性。

第四步:计算统计量。 对每个维度计算均值(M)、标准差(SD)、百分位数分布。如果数据接近正态分布,M 和 SD 就够用了;如果分布偏态严重,还需要报告中位数和四分位距。

第五步:建立 T 分常模表。 根据 M 和 SD,可以算出任意原始分对应的 T 分和百分位。

public class NormBuilder {

    public Norm build(String population, int sampleSize,
                      Map<String, List<Double>> dimensionScores) {
        Norm norm = new Norm();
        norm.setPopulation(population);
        norm.setSampleSize(sampleSize);
        norm.setEstablishedDate(LocalDate.now());

        Map<String, DimensionNorm> dimNorms = new HashMap<>();
        for (Map.Entry<String, List<Double>> entry : dimensionScores.entrySet()) {
            List<Double> scores = entry.getValue();
            double mean = scores.stream().mapToDouble(d -> d).average().orElse(0);
            double variance = scores.size() > 1
                ? scores.stream().mapToDouble(d -> Math.pow(d - mean, 2)).sum()
                    / (scores.size() - 1)
                : 0;
            double sd = Math.sqrt(variance);
            List<Double> sortedScores = scores.stream().sorted().collect(Collectors.toList());

            DimensionNorm dimNorm = new DimensionNorm();
            dimNorm.setMean(mean);
            dimNorm.setSd(sd);
            dimNorm.setPercentiles(buildPercentiles(sortedScores));
            dimNorms.put(entry.getKey(), dimNorm);
        }

        norm.setDimensions(dimNorms);
        return norm;
    }

    private Map<Integer, Double> buildPercentiles(List<Double> sortedScores) {
        // 计算 P10, P25, P50, P75, P90
        Map<Integer, Double> percentiles = new HashMap<>();
        for (int p : List.of(10, 25, 50, 75, 90)) {
            int index = (int) Math.ceil(p / 100.0 * sortedScores.size()) - 1;
            percentiles.put(p, sortedScores.get(Math.max(0, index)));
        }
        return percentiles;
    }
}

常模的存储

常模作为配置数据存储在系统中:

{
  "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 }
      }
    }
  ]
}

统计服务按量表版本、目标人群和任务冻结的规则选择常模。没有匹配项时返回“无适用常模”,原始分仍可保存,但不能自动给出群体相对位置。任何人工覆盖都记录操作者、所选参照组和原因。

常模更新

常模版本包含适用群体、采样时间、样本量、统计方法和生效范围。新版本发布后,历史答卷继续引用原版本,新答卷按任务快照选择版本。系统不根据当前日期静默替换历史报告中的参照数据。


四、数据导出

研究者下一步往往是把数据导出来,用 SPSS、R 或 Python 做更深入的分析。

Excel 导出

用 EasyExcel,支持动态表头和大数据量流式写入:

public void exportToExcel(Long questionnaireId, HttpServletResponse response)
        throws IOException {
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setHeader("Content-Disposition",
        "attachment;filename=questionnaire_" + questionnaireId + ".xlsx");

    List<Map<String, Object>> records = demoRepo.exportFullData(questionnaireId);

    List<List<String>> head = List.of(
        List.of("答卷ID"), List.of("性别"), List.of("年龄"),
        List.of("学历"), List.of("总分"), List.of("提交时间")
    );

    List<List<Object>> data = records.stream().map(r -> {
        List<Object> row = new ArrayList<>();
        row.add(r.get("response_id"));
        row.add(translateGender((String) r.get("gender")));
        row.add(r.get("age"));
        row.add(translateEducation((String) r.get("education")));
        row.add(r.get("total_score"));
        row.add(r.get("created_at"));
        return row;
    }).collect(Collectors.toList());

    EasyExcel.write(response.getOutputStream())
        .head(head).sheet("问卷数据").doWrite(data);
}

导出时要对枚举值做翻译,数据库里存的是 male,Excel 里应该显示"男"。

SPSS 导出

SPSS 的 .sav 格式需要预定义变量类型和宽度:

public void exportToSpss(Long questionnaireId, OutputStream os) throws IOException {
    SpssWriter writer = new SpssWriter(os);
    writer.addNumericVariable("age", 0, "年龄");
    writer.addStringVariable("gender", 10, "性别");
    writer.addStringVariable("education", 20, "学历");
    writer.addNumericVariable("total_score", 2, "量表总分");

    for (Map<String, Object> record : demoRepo.exportFullData(questionnaireId)) {
        writer.writeRow(record);
    }
    writer.close();
}

CSV 导出

更轻量的格式,记得加 BOM 头(\uFEFF),否则 Excel 打开中文会乱码;字段也要按 CSV 规则转义,不能直接 String.join

PrintWriter writer = response.getWriter();
writer.write('\uFEFF');  // BOM
writer.println("答卷ID,性别,年龄,学历,总分");
for (Map<String, Object> record : records) {
    List<String> row = List.of(
        String.valueOf(record.get("response_id")),
        translateGender((String) record.get("gender")),
        String.valueOf(record.get("age")),
        translateEducation((String) record.get("education")),
        String.valueOf(record.get("total_score"))
    );
    writer.println(row.stream().map(this::escapeCsv).collect(Collectors.joining(",")));
}

private String escapeCsv(String value) {
    if (value == null) return "";
    return "\"" + value.replace("\"", "\"\"") + "\"";
}

大数据量场景(十万条以上)用 Stream 流式查询配合分批写入,不能一次性加载到内存。


五、数据脱敏与隐私保护

问卷数据涉及个人隐私,脱敏不是可选项。

脱敏规则

public class DataMasker {

    public static String maskPhone(String phone) {
        if (phone == null || phone.length() < 7) return "***";
        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
    }

    public static String maskName(String name) {
        if (name == null || name.isEmpty()) return "***";
        if (name.length() <= 2) return name.charAt(0) + "*";
        return name.charAt(0) + "*".repeat(name.length() - 2) + name.charAt(name.length() - 1);
    }

    public static Map<String, Object> maskRecord(Map<String, Object> record) {
        Map<String, Object> masked = new HashMap<>(record);
        if (masked.containsKey("name")) masked.put("name", maskName((String) masked.get("name")));
        if (masked.containsKey("phone")) masked.put("phone", maskPhone((String) masked.get("phone")));
        return masked;
    }
}

角色分级与导出权限

public List<Map<String, Object>> exportWithPermission(Long questionnaireId,
                                                       UserRole role) {
    List<Map<String, Object>> data = demoRepo.exportFullData(questionnaireId);
    if (role == UserRole.RESEARCHER) {
        return data.stream().map(DataMasker::maskRecord).collect(Collectors.toList());
    }
    return data;
}

管理员可以导出全量,研究者只能拿到脱敏后的版本。这个权限控制必须在后端数据层拦截。

导出前合规检查

public ComplianceCheckResult checkBeforeExport(Long questionnaireId,
                                                 ExportRequest request) {
    List<String> warnings = new ArrayList<>();

    // 检查敏感字段
    Set<String> sensitiveFields = Set.of("id_card", "phone", "name", "address");
    Set<String> exposed = new HashSet<>(sensitiveFields);
    exposed.retainAll(Set.copyOf(request.getIncludeFields()));
    if (!exposed.isEmpty()) {
        warnings.add("导出包含敏感字段: " + exposed);
    }

    // 小样本反推身份检查
    long count = demoRepo.countByQuestionnaire(questionnaireId);
    if (count < 10) {
        warnings.add("样本量不足 10 人,交叉分析可能导致身份反推");
    }

    return new ComplianceCheckResult(warnings.isEmpty(), warnings);
}

小样本反推身份是隐私保护中容易被忽略的坑。一个只有 5 人的性别×学历交叉表,即使脱敏了姓名,也很容易通过已知信息锁定具体的人。


六、大屏数据可视化

WebSocket 实时推送

新答卷提交时,通过 WebSocket 推送当前公开统计数据:

@Component
public class DashboardWebSocketHandler extends TextWebSocketHandler {

    private final Map<String, Set<WebSocketSession>> sessions = new ConcurrentHashMap<>();

    public void pushUpdate(Long questionnaireId) {
        String dashboardId = "dashboard:" + questionnaireId;
        Set<WebSocketSession> target = sessions.getOrDefault(dashboardId, Set.of());

        DashboardData data = buildDashboardData(questionnaireId);
        String payload = JsonUtil.toJson(data);

        for (WebSocketSession session : target) {
            if (session.isOpen()) {
                try {
                    session.sendMessage(new TextMessage(payload));
                } catch (IOException e) {
                    log.error("推送大屏数据失败", e);
                }
            }
        }
    }

    private DashboardData buildDashboardData(Long questionnaireId) {
        DashboardData data = new DashboardData();
        data.setTotalResponses(demoRepo.countByQuestionnaire(questionnaireId));
        data.setTodayResponses(demoRepo.countToday(questionnaireId));
        data.setGenderDistribution(demoRepo.countByGender(questionnaireId));
        data.setAgeDistribution(demoRepo.countByAgeGroup(questionnaireId));
        data.setScoreDistribution(demoRepo.scoreDistribution(questionnaireId));
        data.setRecentTrend(demoRepo.dailyTrend(questionnaireId, 30));
        return data;
    }
}

前端 ECharts 图表

大屏用深色背景配亮色图表,四个核心图表覆盖管理者更关心的维度:

<template>
  <div class="dashboard">
    <div class="header">
      <h1>问卷数据实时大屏</h1>
      <span class="total">累计答卷: {{ data.totalResponses }}</span>
      <span class="today">今日新增: {{ data.todayResponses }}</span>
    </div>
    <div class="charts-grid">
      <div class="chart-card">
        <h3>性别分布</h3>
        <div ref="genderChart" class="chart-container"></div>
      </div>
      <div class="chart-card">
        <h3>年龄分布</h3>
        <div ref="ageChart" class="chart-container"></div>
      </div>
      <div class="chart-card">
        <h3>量表分数分布</h3>
        <div ref="scoreChart" class="chart-container"></div>
      </div>
      <div class="chart-card wide">
        <h3> 30 天提交趋势</h3>
        <div ref="trendChart" class="chart-container"></div>
      </div>
    </div>
  </div>
</template>

WebSocket 的 onmessage 回调里直接更新 ECharts 实例的 data,图表自动重绘。前端需要实现指数退避重连,网络抖动是常态。

筛选器

后端提供动态 WHERE 条件的查询接口:

@GetMapping("/filter")
public DashboardData filterData(
        @RequestParam Long questionnaireId,
        @RequestParam(required = false) String gender,
        @RequestParam(required = false) String education) {

    StringBuilder where = new StringBuilder("WHERE questionnaire_id = ?");
    List<Object> params = new ArrayList<>();
    params.add(questionnaireId);

    if (gender != null) {
        where.append(" AND JSON_UNQUOTE(JSON_EXTRACT(d.data, '$.gender')) = ?");
        params.add(gender);
    }
    if (education != null) {
        where.append(" AND JSON_UNQUOTE(JSON_EXTRACT(d.data, '$.education')) = ?");
        params.add(education);
    }

    return buildFilteredDashboardData(where.toString(), params);
}

筛选条件通过 JSON 字段的 JSON_EXTRACT 实现,这也是为什么存储设计选了 JSON 字段方案,到这里就体现出优势了。


系列回顾

写到这里,「问卷考试系统」系列七篇文章全部完成。回头看,整个系统围绕一个核心理念展开:用 DSL 驱动配置,用配置替代硬编码

系列内容总览

篇章主题核心内容
第一篇架构与数据模型为什么自建、三引擎架构、7 张核心表
第二篇DSL 设计10 种题型 JSON 规范、条件跳题、版本管理
第三篇评分引擎维度计分、反向题、公式 DSL、常模与标准分
第四篇质量控制测谎题、Z3 组卷验证、异常模式检测
第五篇前端引擎注册表渲染、Pinia 状态、防作弊、移动端
第六篇报告与 AI模板引擎、规则引擎、AI 解读、PDF 导出
第七篇统计与可视化T 检验、ANOVA、常模建立、大屏、数据导出

核心设计思想

  1. DSL 是灵魂,题目结构、评分规则、报告模板、人口学字段,全部用 DSL 描述。新增问卷只需要写配置,不碰业务代码
  2. 引擎是骨架,DSL 引擎、评分引擎、报告引擎各自独立,互不耦合。改一个不影响其他
  3. 常模是参照系,并非"标准答案",重点是群体分数分布。没有常模,原始分只是数字
  4. 质量控制是保障,测谎题 + Z3 约束验证 + 异常模式检测,三道防线交叉验证
  5. AI 是加分项,文字解读让报告说人话,但降级机制保证 AI 挂了系统照样能用

如果继续往前走

  1. 多语言支持:DSL 的 label 和 options 增加 i18n 字段
  2. 纵向追踪:同一批受试者多次施测,分析随时间变化的趋势
  3. 常模定期校准:按新样本和新版本定期重算常模,保留旧常模用于历史答卷复现
  4. API 开放平台:标准化 REST API,让第三方系统接入问卷数据做二次分析

整个系统从 DSL 驱动出发,走完了问卷设计、答题、评分、报告、统计的完整闭环。DSL 的核心价值在于把变化点收拢到配置里:新问卷上线通常只需要新增 DSL、校验规则和报告模板,主流程尽量不动。这个模式在心理测评、教育考试、满意度调查等场景都有很强的复用价值。

系列完结。感谢一路读到这里。

上一篇:问卷考试系统设计(六):报告生成