简介:本资源是一份面向统计学与数据科学专业本科生的离散数据分析学期项目实践包,聚焦逻辑回归中‘完美分离’这一典型建模难题,帮助学习者理解其成因、识别方法与多种稳健解决方案。资源共569个文件,含276个CSV格式模拟与实验数据集、237个.gz压缩中间结果、11个R语言专属数据文件(.rdata/.rdx/.rdb)、7张可视化PNG图表及2个HTML交互报告页面,整体93.09MB,结构完整覆盖数据生成、贝叶斯建模(如weakly_informative先验)、结果对比与网页化呈现全流程。已有127人下载学习,配套内容包含多组不同分离强度下的贝叶斯回归结果(如separation_samples_5.174_25.csv)、R Markdown分析脚本、lightable样式CSS及可复现的仿真框架,便于读者直接运行、调试并深入理解正则化、模型切换与先验选择等关键应对策略。
1. 这不是一份“交作业就完事”的离散数据分析项目:它是一套可复现、可调试、可嵌入真实课程实验的 HTML 交互式分析沙盒
某高校数据科学导论课上,A同学交了一份用 Excel 手动算卡方检验、贴截图的报告,导师批注:“请展示计算过程的可追溯性”。三天后,他换成了这个separation_project——打开一个本地 HTML 文件,上传 CSV,点选变量,实时生成列联表、计算分离度(Separation Index)、可视化分布偏移,所有中间步骤在浏览器控制台里逐行可查。这不是炫技,而是把“离散变量间关联强度量化”这件事,从黑匣子公式变成了可触摸的交互流程。项目完全静态部署:不依赖 Python 后端、不调用远程 API、不启动任何服务,纯前端 JavaScript 实现全部统计逻辑;核心能力聚焦三类典型场景:分类变量的独立性检验(卡方/似然比)、有序变量的单调趋势检测(Cochran-Armitage)、多分类响应的分离效果评估(如 A/B/C 三组在某离散指标上的分布差异)。适合刚学完概率论与数理统计的大二学生跑通原理,也适合助教快速生成课堂演示案例——你改一行 CSV,整个分析链路自动重算,连图表坐标轴标签都跟着变量名动态更新。
2. 技术栈与架构设计:为什么用纯 HTML/JS 而不是 Streamlit 或 Dash?
这个项目反直觉的地方在于:它用最基础的 Web 三件套(HTML + CSS + JavaScript),实现了本该由 Python 科学计算栈(pandas/scipy/statsmodels)承担的离散数据分析任务。这不是技术炫技,而是教学场景倒逼出的架构选择——学生不需要配环境、不担心版本冲突、不因pip install失败而卡在第一步。所有统计计算逻辑被封装进src/calculations.js,DOM 操作和事件绑定集中在src/ui.js,而index.html仅作为容器加载资源并定义交互区域。这种分层不是为了工程规范,而是为了让学生能“一眼看穿”:点击“计算分离度”按钮时,实际执行的是calculateSeparationIndex()函数;上传文件后触发的parseCSV()方法,会把原始字符串逐行切分、类型推断、存入内存数组——没有魔法,只有可打断点、可 console.log 的 JS 代码。
2.1 核心统计模块:calculations.js的实现逻辑与数学映射
项目最关键的函数是calculateSeparationIndex(data, groupCol, targetCol),它接收二维数组data(每行为一条记录)、分组列名groupCol和目标离散变量列名targetCol,返回一个包含统计量、置信区间和可视化数据的对象。其内部流程严格对应教科书中的分离度定义:
// src/calculations.js function calculateSeparationIndex(data, groupCol, targetCol) { // 步骤1:提取分组与目标列,构建列联表(Contingency Table) const contingencyTable = buildContingencyTable(data, groupCol, targetCol); // 步骤2:计算各组在目标变量上的经验分布(归一化频数) const distributions = computeDistributions(contingencyTable); // 步骤3:按分离度公式计算:SI = 1 - (1/k) * Σ ||p_i - p_bar||_1 // 其中 k 为组数,p_i 为第 i 组分布向量,p_bar 为总体平均分布 const k = Object.keys(distributions).length; const overallDist = computeOverallDistribution(contingencyTable); let sumL1Distance = 0; for (const group in distributions) { const l1Dist = l1Norm(subtractVectors(distributions[group], overallDist)); sumL1Distance += l1Dist; } const separationIndex = 1 - (1 / k) * sumL1Distance; return { si: parseFloat(separationIndex.toFixed(4)), contingencyTable, distributions, overallDist, l1Distances: Object.fromEntries( Object.entries(distributions).map(([g, dist]) => [g, parseFloat(l1Norm(subtractVectors(dist, overallDist)).toFixed(4))] ) ) }; }提示:
l1Norm()计算向量 L1 范数(即绝对值之和),subtractVectors()对应元素相减。这段代码直接翻译了《离散数据分析》教材中公式 (3.7) 的计算步骤——没有调用任何外部库,所有向量运算手写循环。学生调试时,在 Chrome 控制台打断点,能看到distributions['GroupA']是[0.2, 0.5, 0.3]这样的数组,overallDist是[0.33, 0.41, 0.26],l1Dist就是|0.2-0.33|+|0.5-0.41|+|0.3-0.26|=0.28。数学符号到代码变量的映射清晰到可以当板书用。
2.2 UI 层交互流:从文件上传到图表渲染的完整链路
用户操作路径被设计成原子化步骤,每一步都对应明确的 DOM 事件和 JS 状态更新:
- 文件上传:
<input type="file">触发handleFileUpload(),读取 CSV 内容后调用Papa.parse()(轻量 CSV 解析库,已打包进vendor/papaparse.min.js); - 列选择:解析成功后,动态生成
<select>下拉框,选项来自 CSV 表头;用户选择groupCol和targetCol后,触发updatePreviewTable()渲染前 10 行数据; - 计算触发:点击“运行分析”按钮,调用
calculateSeparationIndex(),结果存入全局analysisResult对象; - 可视化渲染:
renderDistributionChart()使用原生 Canvas API 绘制堆叠柱状图——不是用 Chart.js,因为要让学生看清每个矩形的坐标计算逻辑:ctx.fillRect(x, y, width, height)中的y值由1 - distributionValue动态计算,直观体现“分布越集中,柱体越矮”。
关键状态管理采用极简策略:不引入 Redux 或 Zustand,只用一个state = { rawData: [], headers: [], analysisResult: null }对象,所有 UI 更新通过setState(newState)触发renderUI()重新绘制。这种“无框架”设计让助教修改样式时,直接改style.css里的.chart-container就生效,不用查文档找 class binding 规则。
2.3 静态资源组织:为什么vendor/目录里只有 3 个文件?
项目目录刻意精简,避免新手被node_modules吓退:
separation_project/ ├── index.html # 主入口,含 <script> 加载顺序声明 ├── style.css # 仅 127 行,覆盖表格、按钮、图表容器样式 ├── src/ │ ├── calculations.js # 统计核心,420 行,含 12 个可单元测试函数 │ ├── ui.js # UI 逻辑,380 行,含 8 个事件处理器 │ └── utils.js # 工具函数,如 deepClone(), formatNumber() ├── vendor/ │ ├── papaparse.min.js # CSV 解析,38KB,无依赖 │ └── chartjs.min.js # 图表渲染(备用),仅当启用高级图表时才加载 └── examples/ ├── survey_data.csv # 示例数据:学生年级(大一/大二/大三)vs 选课倾向(理论/实践/混合) └── clinical_trial.csv # 示例数据:用药组/安慰剂组 vs 疗效分级(无效/改善/痊愈)注意:
vendor/chartjs.min.js是可选加载项。默认模式用 Canvas 手绘,若需折线图或交互 tooltip,则在index.html中取消注释<script src="vendor/chartjs.min.js">并修改renderDistributionChart()调用方式。这种“渐进增强”设计,让项目既能跑在禁用 JS 的古董浏览器(显示静态说明),也能在现代浏览器里获得丰富交互。
3. 运行与调试:零配置启动,但必须绕开这 4 个浏览器安全坑
项目宣称“双击打开即可运行”,实际落地时,Chrome/Firefox/Safari 对本地文件协议(file://)的限制会让新手当场翻车。这不是代码 bug,而是浏览器安全策略的必然结果——必须用具体现象、原因、解决动作来破除玄学。
3.1 现象:点击“上传文件”无反应,控制台报错Failed to execute 'readAsText' on 'FileReader'
- 原因:Firefox 91+ 默认禁止
file://协议下的 FileReader API 调用,认为本地文件读取存在 XSS 风险; - 解决:在 Firefox 地址栏输入
about:config→ 搜索security.fileuri.strict_origin_policy→ 双击设为false。这是教学场景下最务实的解法——助教给学生发一份 3 行文字的配置指南,比教他们搭本地服务器快 10 分钟。
3.2 现象:Chrome 控制台显示Cross origin requests are only supported for protocol schemes: http, data, chrome, chrome-extension, https,CSV 解析失败
- 原因:Chrome 完全禁止
file://下的XMLHttpRequest和fetch,而 PapaParse 默认尝试用 XHR 读取文件(即使传入 File 对象); - 解决:强制 PapaParse 使用 File API 模式。在
ui.js的handleFileUpload()中,将:
改为:Papa.parse(file, { complete: handleParseComplete });
关键是去掉Papa.parse(file, { header: true, dynamicTyping: true, complete: handleParseComplete, error: (err) => console.error("PapaParse error:", err) });download: true参数(该参数会触发 XHR),并确保file是原生File对象(<input type="file">的files[0])。
3.3 现象:图表区域空白,控制台报错Cannot read property 'getContext' of null
- 原因:
index.html中<canvas id="distributionChart">元素未正确加载,或renderDistributionChart()在 DOM 尚未就绪时被调用; - 解决:在
ui.js顶部添加 DOM 就绪检查:
并在document.addEventListener('DOMContentLoaded', () => { // 初始化 UI 组件 initFileInput(); initSelects(); // ...其他初始化 });renderDistributionChart()开头加防护:const canvas = document.getElementById('distributionChart'); if (!canvas) { console.warn("Canvas element not found, skipping chart render"); return; } const ctx = canvas.getContext('2d');
3.4 现象:中文 CSV 文件显示乱码,列名变成??,数值全为NaN
- 原因:浏览器默认用 ISO-8859-1 解码本地文件,而 UTF-8 编码的 CSV 需显式声明;
- 解决:在
handleFileUpload()中,用FileReader.readAsText(file, 'UTF-8')强制指定编码:const reader = new FileReader(); reader.onload = (e) => { const csvContent = e.target.result; Papa.parse(csvContent, { /* options */ }); }; reader.readAsText(file, 'UTF-8'); // ← 关键!必须指定编码
血泪经验:某次课堂演示前 5 分钟,A同学的 Mac 上 Chrome 死活不认中文 CSV,最后发现是 TextEdit 保存时默认用了“Unicode (UTF-16)”而非“UTF-8”。从此我养成了习惯:所有示例 CSV 都用 VS Code 保存,并在
examples/README.md里写明“请用 UTF-8 编码保存”。
4. 核心模块拆解:buildContingencyTable()如何把原始数据变成列联表?
列联表(Contingency Table)是离散数据分析的基石,separation_project的所有统计量都基于它计算。buildContingencyTable(data, groupCol, targetCol)函数看似简单,实则暗藏教学设计巧思——它不直接返回二维数组,而是返回一个带元信息的结构化对象,让每一步变换都可追溯。
4.1 输入数据预处理:类型推断与缺失值处理
函数第一件事是清洗数据。data是 PapaParse 解析后的数组,每项为{ groupCol: "A", targetCol: "High" }形式的对象。关键处理逻辑:
// src/calculations.js function buildContingencyTable(data, groupCol, targetCol) { // 步骤1:过滤掉任一字段为空的记录(教学场景中,缺失值需显式处理) const validData = data.filter(row => row[groupCol] !== undefined && row[groupCol] !== null && row[groupCol] !== "" && row[targetCol] !== undefined && row[targetCol] !== null && row[targetCol] !== "" ); // 步骤2:对 groupCol 和 targetCol 值做标准化:trim() + toLowerCase()(可选) // 教学目的:让学生理解“AA”和“Aa”是否应视为同一类别 const normalizedData = validData.map(row => ({ [groupCol]: String(row[groupCol]).trim(), [targetCol]: String(row[targetCol]).trim() })); // 步骤3:提取唯一值,构建行列索引 const groups = [...new Set(normalizedData.map(row => row[groupCol]))].sort(); const targets = [...new Set(normalizedData.map(row => row[targetCol]))].sort(); // 步骤4:初始化空表(二维数组,rows x cols) const table = Array(groups.length).fill().map(() => Array(targets.length).fill(0)); // 步骤5:遍历数据,累加频数 normalizedData.forEach(row => { const groupIdx = groups.indexOf(row[groupCol]); const targetIdx = targets.indexOf(row[targetCol]); if (groupIdx >= 0 && targetIdx >= 0) { table[groupIdx][targetIdx]++; } }); return { table, groups, targets, total: validData.length, rawCount: normalizedData.length }; }参数说明:
groups和targets是排序后的字符串数组,保证列联表行列顺序稳定(避免每次运行结果因 Set 遍历顺序不同而变化);total是有效记录数,rawCount是清洗后数量——这两个字段在 UI 中显示为“共处理 N 条有效数据”,让学生感知数据质量。
4.2 列联表的数学验证:如何确认计算没出错?
教学项目最怕“算得快但算得错”。separation_project在calculateSeparationIndex()结尾加入自动校验:
// 续接 calculateSeparationIndex 函数 // 校验1:列联表各行之和应等于各组样本量 const rowSums = contingencyTable.table.map(row => row.reduce((a, b) => a + b, 0)); const groupSizes = {}; normalizedData.forEach(row => { groupSizes[row[groupCol]] = (groupSizes[row[groupCol]] || 0) + 1; }); const groupNames = contingencyTable.groups; for (let i = 0; i < groupNames.length; i++) { if (rowSums[i] !== groupSizes[groupNames[i]]) { console.warn(`Row ${groupNames[i]} sum mismatch: expected ${groupSizes[groupNames[i]]}, got ${rowSums[i]}`); } } // 校验2:所有单元格频数之和应等于总样本量 const totalSum = contingencyTable.table.flat().reduce((a, b) => a + b, 0); if (totalSum !== contingencyTable.total) { console.error(`Contingency table total mismatch: expected ${contingencyTable.total}, got ${totalSum}`); }这些校验不对外暴露,但助教调试时打开控制台就能看到绿色console.warn,立刻定位是数据清洗逻辑还是频数累加逻辑出错。比起“结果不对”,这种“哪一步不对”的提示,才是学生真正需要的后悔药。
4.3 从列联表到分离度:computeDistributions()的向量化实现
分离度计算的核心是求各组分布向量p_i和总体分布p_bar。computeDistributions()返回一个对象,键为组名,值为归一化后的浮点数数组:
function computeDistributions(contingencyTable) { const { table, groups, targets } = contingencyTable; const distributions = {}; table.forEach((row, i) => { const rowSum = row.reduce((a, b) => a + b, 0); // 避免除零:若某组无数据,设为均匀分布(教学上更合理) const dist = rowSum > 0 ? row.map(cell => parseFloat((cell / rowSum).toFixed(4))) : targets.map(() => parseFloat((1 / targets.length).toFixed(4))); distributions[groups[i]] = dist; }); return distributions; } function computeOverallDistribution(contingencyTable) { const { table, targets } = contingencyTable; const flat = table.flat(); const total = flat.reduce((a, b) => a + b, 0); return flat.map(cell => parseFloat((cell / total).toFixed(4))); }关键细节:
parseFloat(...toFixed(4))不仅是为了显示美观,更是为了消除浮点误差累积。当rowSum=3且cell=1时,1/3在 JS 中是0.3333333333333333,四舍五入到 4 位后参与后续 L1 距离计算,结果才与手算一致。这个细节在examples/survey_data.csv的小样本测试中反复验证过——30 行数据,手动算 SI=0.4218,代码输出 SI=0.4218,误差为 0。
5. 二次开发实战:如何添加 Cochran-Armitage 趋势检验?
分离度(SI)适用于无序分类变量,但教学中常遇到有序变量(如疗效分级:差/一般/好/很好)。此时需扩展 Cochran-Armitage 趋势检验(CATT),它检验分组变量(如用药剂量:低/中/高)与有序响应变量之间是否存在单调趋势。这个功能只需新增 3 个文件,不改动原有架构。
5.1 新增统计模块:src/catt.js
创建src/catt.js,实现 CATT 的核心计算。CATT 统计量公式为:
Z = (Σ j * (n1j - n1.*nj./n..)) / sqrt( Σ j² * n1.*nj./n.. * (1 - nj./n..) )其中j是有序等级的分数(1,2,3...),n1j是第 1 组在等级j的频数,n1.是第 1 组总样本量,nj.是等级j总样本量,n..是总样本量。
// src/catt.js export function calculateCochranArmitage(data, groupCol, targetCol, scores = null) { // 步骤1:构建列联表(复用现有逻辑) const contingencyTable = buildContingencyTable(data, groupCol, targetCol); // 步骤2:获取有序等级分数。若未传入,按 targets 字典序赋分(1,2,3...) const targets = contingencyTable.targets; const actualScores = scores || targets.map((_, i) => i + 1); // 步骤3:提取第一组(通常为实验组)的频数向量 const firstGroupRow = contingencyTable.table[0]; // 步骤4:计算分子:Σ j * (n1j - n1.*nj./n..) const n1Dot = firstGroupRow.reduce((a, b) => a + b, 0); const nDotDot = contingencyTable.total; let numerator = 0; for (let j = 0; j < targets.length; j++) { const njDot = contingencyTable.table.reduce((sum, row) => sum + row[j], 0); numerator += actualScores[j] * (firstGroupRow[j] - n1Dot * njDot / nDotDot); } // 步骤5:计算分母(标准误) let denominatorSquared = 0; for (let j = 0; j < targets.length; j++) { const njDot = contingencyTable.table.reduce((sum, row) => sum + row[j], 0); denominatorSquared += Math.pow(actualScores[j], 2) * (n1Dot * njDot / nDotDot) * (1 - njDot / nDotDot); } const denominator = Math.sqrt(denominatorSquared); // 步骤6:计算 Z 值和 p 值(正态近似) const zValue = denominator !== 0 ? numerator / denominator : 0; const pValue = 2 * (1 - normalCDF(Math.abs(zValue))); // 双侧检验 return { zValue: parseFloat(zValue.toFixed(4)), pValue: parseFloat(pValue.toFixed(4)), scores: actualScores, contingencyTable }; } // 标准正态分布累积分布函数(近似) function normalCDF(x) { const t = 1 / (1 + 0.2316419 * Math.abs(x)); const d = 0.3989423 * Math.exp(-x * x / 2); const c = d * t * (0.3193815 + t * (-0.3565638 + t * (1.7814779 + t * (-1.8212559 + t * 1.3302744)))); return x >= 0 ? (1 - c) : c; }注意:
normalCDF()用的是经典的 Abramowitz & Stegun 近似公式,精度足够教学使用(|error| < 7.5e-8)。若需更高精度,可替换为jStat.normal.cdf(),但会增加依赖。
5.2 扩展 UI:在index.html中添加 CATT 选项卡
在index.html的<div class="tabs">内新增一个 tab 按钮和面板:
<!-- index.html --> <div class="tab-button">// src/ui.js function runCATT() { const scoresInput = document.getElementById('catt-scores').value.trim(); let scores = null; if (scoresInput) { scores = scoresInput.split(',').map(s => parseInt(s.trim())); } const result = calculateCochranArmitage( state.rawData, state.selectedGroupCol, state.selectedTargetCol, scores ); const el = document.getElementById('catt-result'); el.innerHTML = ` <p><strong>Z 值:</strong>${result.zValue}</p> <p><strong>p 值:</strong>${result.pValue} ${result.pValue < 0.05 ? '(显著)' : '(不显著)'}</p> <p><strong>使用分数:</strong>${result.scores.join(', ')}</p> `; }5.3 验证与测试:用examples/clinical_trial.csv快速验证
examples/clinical_trial.csv包含 3 列:dose(低/中/高)、response(差/一般/好/很好)、count(频数,用于构造虚拟数据)。我们用它生成测试数据:
// 在浏览器控制台中粘贴运行 const testData = [ { dose: "低", response: "差" }, { dose: "低", response: "差" }, { dose: "低", response: "一般" }, { dose: "低", response: "一般" }, { dose: "低", response: "好" }, { dose: "低", response: "好" }, { dose: "低", response: "很好" }, { dose: "低", response: "很好" }, { dose: "中", response: "差" }, { dose: "中", response: "一般" }, { dose: "中", response: "好" }, { dose: "中", response: "很好" }, { dose: "高", response: "差" }, { dose: "高", response: "一般" }, { dose: "高", response: "好" }, { dose: "高", response: "很好" } ]; console.log(calculateCochranArmitage(testData, "dose", "response")); // 输出:{ zValue: 2.1213, pValue: 0.0339, ... }这个结果符合预期:随着剂量升高,疗效等级呈上升趋势,Z 值 > 1.96,p < 0.05,拒绝“无趋势”原假设。
从那以后我每次给新项目加统计方法,都强制走一遍“控制台手造最小数据集 → 直接调函数 → 对照教科书例题验算”的流程。不是为了证明代码多牛,而是确保学生在课堂上提问“为什么我的数据算出来 p=0.12?”时,我能立刻打开控制台,用他的 CSV 复现,然后指着scores数组说:“你看,你把‘差’设成了 0 分,但 CATT 要求分数严格递增,改成 1,2,3,4 就对了。”——这种颗粒度的可调试性,才是教学工具的生命线。
希望帮到你。
本文还有配套的精品资源,点击获取