问卷考试系统设计(七):数据统计、可视化与系列总结
上一篇:问卷考试系统设计(六):报告生成
数据采集只是起点。问卷系统真正产生价值的时刻,是数据沉淀之后,研究者打开统计面板,看到分组差异、趋势变化和数据质量。这篇聚焦数据统计分析、多格式导出、隐私脱敏、大屏可视化,以及常模的建立过程。
统计分析要区分离线口径和实时看板,避免把探索性分析塞进在线请求:
一、描述性统计
拿到一批量表分数,第一件事是看整体面貌:
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、常模建立、大屏、数据导出 |
核心设计思想
- DSL 是灵魂,题目结构、评分规则、报告模板、人口学字段,全部用 DSL 描述。新增问卷只需要写配置,不碰业务代码
- 引擎是骨架,DSL 引擎、评分引擎、报告引擎各自独立,互不耦合。改一个不影响其他
- 常模是参照系,并非"标准答案",重点是群体分数分布。没有常模,原始分只是数字
- 质量控制是保障,测谎题 + Z3 约束验证 + 异常模式检测,三道防线交叉验证
- AI 是加分项,文字解读让报告说人话,但降级机制保证 AI 挂了系统照样能用
如果继续往前走
- 多语言支持:DSL 的 label 和 options 增加 i18n 字段
- 纵向追踪:同一批受试者多次施测,分析随时间变化的趋势
- 常模定期校准:按新样本和新版本定期重算常模,保留旧常模用于历史答卷复现
- API 开放平台:标准化 REST API,让第三方系统接入问卷数据做二次分析
整个系统从 DSL 驱动出发,走完了问卷设计、答题、评分、报告、统计的完整闭环。DSL 的核心价值在于把变化点收拢到配置里:新问卷上线通常只需要新增 DSL、校验规则和报告模板,主流程尽量不动。这个模式在心理测评、教育考试、满意度调查等场景都有很强的复用价值。
系列完结。感谢一路读到这里。
上一篇:问卷考试系统设计(六):报告生成