简介:这份资源是一套基于C#与Chart控件开发的医疗数据分析项目,面向具备一定.NET基础的开发者与医疗信息化学习者,用于处理高血压患者生活习惯与健康指标的关联分析。压缩包共68个文件,约1.23MB,以cs源代码、exe可执行程序、resx与resources资源文件、config配置及sln解决方案为主,另含xls数据表、dll依赖与pdb调试文件,覆盖从界面窗体、数据模型到图表生成与数据库脚本的完整结构。项目围绕血压变化、饮食、运动、睡眠等指标展开可视化呈现,可辅助医生与患者直观理解行为模式对健康的影响,并涉及医疗数据隐私与安全合规的考量。目前已有570人学习下载,适合作为C#桌面应用与医疗数据分析的实战参考,帮助读者掌握数据导入清洗、图表绘制与业务逻辑组织的实现思路。
1. C#数据分析:为什么你的第一反应不该是Python
如果你是一名C#开发者,手头有一个需要做数据分析的需求——比如从一批CSV日志里统计接口耗时分布、从SQL Server里拉出订单数据做同比环比、或者给WinForms/WPF客户端加一个本地报表模块——你的第一反应可能是“切到Python去”。这个反应很自然,但未必正确。C#做数据分析并不是什么退而求其次的选择,在.NET生态已经成熟的今天,它完全能覆盖从数据读取、清洗、统计计算到可视化输出的完整链路,而且有一个Python给不了的优势:你的分析代码可以直接跑在已有的C#业务系统里,不需要跨语言调用、不需要额外维护一套Python运行时。
这篇文章面向的是有C#基础、但没系统做过数据分析的开发者。我会把“用C#做数据分析”拆成一条可复现的路径:先讲清楚.NET生态里做数据分析到底有哪些可用的库和工具,再落到具体的数据读取、清洗、统计、导出四个环节,每个环节给出能直接跑的代码和参数说明,最后讲我在实际项目里踩过的坑和验证方法。读完之后,你应该能判断自己的场景适不适合用C#做,以及如果适合,第一步该怎么动手。
2. 选型先定框架:C#数据分析的库怎么挑
2.1 三条技术路线和它们的适用边界
在C#里做数据分析,目前主流有三条路线,选错了后面全是返工。
第一条是纯.NET原生库路线,核心是Microsoft.Data.Analysis(简称MDA)。这是微软官方出的DataFrame实现,API设计参考了Pandas,支持列式存储、基础统计、过滤、分组。优点是纯托管代码、跨平台、和.NET项目无缝集成;缺点是功能相对基础,没有内置的绘图能力,复杂的窗口函数和透视操作需要自己写。
第二条是ML.NET路线。ML.NET是.NET的机器学习框架,但它的数据转换管道(Data Transformation Pipeline)其实也能拿来做数据分析。比如用TextLoader读CSV、用NormalizeMinMax做归一化、用DetectAnomalyBySrCnn做异常检测。适合的场景是:你的分析目标本身带有预测或分类性质,比如“从历史销售数据里找出异常订单”。
第三条是互操作路线,通过Python.NET或IronPython在C#里调用Pandas/NumPy。这条路我不推荐作为首选,原因很简单:你引入了Python运行时依赖,部署复杂度陡增,而且调试跨语言调用的问题非常痛苦。只有在团队已经有成熟的Python分析脚本、且不想重写的情况下才考虑。
我一般的选型逻辑是:纯统计和报表用MDA,带预测/分类的用ML.NET,已有Python资产且不想动的才走互操作。下面这张表可以帮你快速对照。
| 维度 | Microsoft.Data.Analysis | ML.NET | Python.NET互操作 |
|---|---|---|---|
| 学习成本 | 低,API类似Pandas | 中,需理解管道概念 | 高,需同时懂两边 |
| 部署依赖 | 无额外依赖 | 无额外依赖 | 需Python运行时 |
| 统计计算 | 基础统计齐全 | 偏ML预处理 | 最全 |
| 可视化 | 需配合其他库 | 无 | 可用Matplotlib |
| 适合场景 | 报表、清洗、统计 | 异常检测、预测 | 已有Python资产 |
2.2 用dotnet CLI搭起最小可跑环境
选型定了之后,第一步是把环境搭起来。不管你用Visual Studio还是VS Code,我建议用dotnet CLI来初始化,因为命令清晰、可复现,也方便你直接抄。
# 创建控制台项目,命名为DataAnalysisDemo dotnet new console -n DataAnalysisDemo cd DataAnalysisDemo # 添加Microsoft.Data.Analysis包 dotnet add package Microsoft.Data.Analysis # 如果后续要用ML.NET,再加这个 dotnet add package Microsoft.ML # 确认包版本已解析 dotnet list package这三条命令做完,你的项目文件里会多出两个PackageReference。dotnet list package的输出应该能看到Microsoft.Data.Analysis和Microsoft.ML的版本号。这里有个细节:MDA的版本要和你的.NET目标框架匹配,如果你用的是.NET 6/7/8,直接装最新稳定版就行;如果是.NET Framework 4.x,MDA支持有限,建议升级或改用其他方案。
环境搭好之后,我习惯先写一个最小的DataFrame创建和打印,确认库能正常工作。
using Microsoft.Data.Analysis; // 用两个数组创建一个最简单的DataFrame var names = new StringDataFrameColumn("Name", new[] { "Alice", "Bob", "Carol" }); var scores = new DoubleDataFrameColumn("Score", new[] { 85.5, 92.0, 78.5 }); var df = new DataFrame(names, scores); // 打印前几行,确认数据正确 Console.WriteLine(df);这段代码的逻辑很直白:StringDataFrameColumn和DoubleDataFrameColumn分别创建字符串列和浮点列,然后传给DataFrame构造函数。运行后控制台会输出一个表格。如果你看到表格正常打印,说明环境没问题。参数方面,列名是第一个参数,必须唯一;数据数组的长度必须一致,否则构造时会抛异常。
3. 数据读取与清洗:从CSV和数据库把数据搬进来
3.1 用TextLoader读CSV:列类型和分隔符怎么设
实际项目里,数据很少是内存里现成的数组,最常见的是CSV文件和数据库。先讲CSV。ML.NET的TextLoader是读CSV最稳的方式,因为它对列类型的推断和错误处理比较完善。
using Microsoft.ML; using Microsoft.ML.Data; // 定义数据模型,列名要和CSV表头对应 public class ApiLog { [LoadColumn(0)] public string Endpoint { get; set; } [LoadColumn(1)] public float ElapsedMs { get; set; } [LoadColumn(2)] public DateTime Timestamp { get; set; } [LoadColumn(3)] public bool IsSuccess { get; set; } } var mlContext = new MLContext(); // 读取CSV,指定分隔符和是否有表头 var loader = mlContext.Data.CreateTextLoader<ApiLog>( separatorChar: ',', hasHeader: true, allowQuoting: true); var data = loader.Load("api_logs.csv"); var preview = data.Preview();这段代码的关键在CreateTextLoader的三个参数。separatorChar指定分隔符,CSV一般是逗号,但有些系统导出的是制表符或分号,要按实际情况改。hasHeader告诉加载器第一行是列名还是数据,如果CSV没有表头就设false,但那样你就得靠LoadColumn的序号来对应列。allowQuoting处理字段里包含逗号的情况,比如某个字段值是"GET /api, v2",开启后能正确解析。
LoadColumn特性里的序号是CSV列的索引,从0开始。这里有个容易翻车的地方:如果CSV列顺序变了,你的模型属性对应关系就全乱了。我的习惯是在读取之后立刻做一次列校验,确认关键列的数据类型和预期一致。
// 检查ElapsedMs列是否有异常值 var elapsedColumn = data.GetColumn<float>("ElapsedMs"); var stats = mlContext.Data.CreateEnumerable<ApiLog>(data, reuseRowObject: false) .Select(x => x.ElapsedMs) .Where(x => x >= 0) .ToList(); Console.WriteLine($"有效记录数: {stats.Count}"); Console.WriteLine($"平均耗时: {stats.Average():F2} ms"); Console.WriteLine($"P95耗时: {stats.OrderBy(x => x).ElementAt((int)(stats.Count * 0.95)):F2} ms");这里用CreateEnumerable把IDataView转成可枚举的ApiLog对象,然后做过滤和统计。reuseRowObject: false很重要,如果设成true,每次迭代会复用同一个对象,导致你拿到的所有行都是最后一行。这个坑我在第一次用ML.NET时踩过,排查了半天才发现是参数问题。
3.2 从SQL Server拉数据:DataTable和DataFrame的桥接
数据库场景更常见。假设你要从SQL Server里拉订单数据做分析,最直接的方式是用SqlDataAdapter填充DataTable,再转成DataFrame。
using System.Data; using System.Data.SqlClient; using Microsoft.Data.Analysis; // 从数据库读取数据到DataTable var connectionString = "Server=localhost;Database=SalesDb;Trusted_Connection=True;"; var query = @" SELECT OrderId, CustomerId, OrderDate, TotalAmount, Status FROM Orders WHERE OrderDate >= @StartDate"; using var conn = new SqlConnection(connectionString); using var cmd = new SqlCommand(query, conn); cmd.Parameters.AddWithValue("@StartDate", new DateTime(2024, 1, 1)); var adapter = new SqlDataAdapter(cmd); var table = new DataTable(); adapter.Fill(table); // 把DataTable转成DataFrame var df = table.ToDataFrame();ToDataFrame()是MDA提供的扩展方法,能把DataTable直接转成DataFrame。但这里有个性能注意点:如果数据量超过几十万行,adapter.Fill会把所有数据一次性加载到内存,可能撑爆。我的做法是分批读取,比如按月份分多次查询,每次处理完再合并统计结果。
// 分批读取的写法:按月循环 var monthlyStats = new List<(int Month, double Total)>(); for (int month = 1; month <= 12; month++) { var start = new DateTime(2024, month, 1); var end = start.AddMonths(1); cmd.Parameters["@StartDate"].Value = start; cmd.Parameters["@EndDate"].Value = end; table.Clear(); adapter.Fill(table); var monthTotal = table.AsEnumerable() .Sum(row => row.Field<decimal>("TotalAmount")); monthlyStats.Add((month, (double)monthTotal)); }分批的核心是把@StartDate和@EndDate作为参数在循环里改,每次只拉一个月的数据。这样内存占用可控,而且如果某个月查询失败,不会影响其他月份。参数化查询还能防止SQL注入,比字符串拼接安全得多。
3.3 清洗的四个常规动作:去重、补空、类型转换、异常值处理
数据拉进来之后,清洗是绕不开的。我总结下来,80%的清洗工作就是四件事:去重、补空、类型转换、异常值处理。MDA提供了对应的方法。
// 1. 去重:按OrderId去重 var distinctDf = df.DropDuplicates(new[] { "OrderId" }); // 2. 补空:把TotalAmount的空值替换为0 var amountCol = distinctDf["TotalAmount"] as DoubleDataFrameColumn; for (long i = 0; i < amountCol.Length; i++) { if (amountCol[i] == null) amountCol[i] = 0; } // 3. 类型转换:把字符串日期转成DateTime var dateCol = distinctDf["OrderDate"] as StringDataFrameColumn; var parsedDates = dateCol.Select(s => DateTime.Parse(s)).ToArray(); distinctDf.Columns.Add(new DateTimeDataFrameColumn("ParsedDate", parsedDates)); // 4. 异常值处理:把TotalAmount超过100万的标记出来 var outliers = distinctDf.Filter( distinctDf["TotalAmount"].ElementwiseGreaterThan(1_000_000)); Console.WriteLine($"异常订单数: {outliers.Rows.Count}");DropDuplicates接受一个列名数组,按这些列的组合去重。补空这里我用了循环逐行检查,因为MDA没有内置的FillNa,这是它和Pandas的一个差距。类型转换需要新建一列,因为DataFrameColumn的类型是固定的,不能原地改。异常值用ElementwiseGreaterThan做逐元素比较,返回一个布尔列,再传给Filter。
注意:MDA的列操作很多是返回新列而不是原地修改,如果你习惯Pandas的inplace风格,需要适应一下。每次操作后检查一下列是否真的加进去了。
4. 统计计算与分组聚合:把数字变成结论
4.1 描述性统计:均值、中位数、分位数的C#写法
数据清洗完,下一步是算统计量。MDA内置了Mean、Median、Min、Max、Sum这些方法,但分位数需要自己算。
var amountCol = df["TotalAmount"] as DoubleDataFrameColumn; // 内置统计量 Console.WriteLine($"均值: {amountCol.Mean():F2}"); Console.WriteLine($"中位数: {amountCol.Median():F2}"); Console.WriteLine($"标准差: {amountCol.StdDev():F2}"); Console.WriteLine($"最小值: {amountCol.Min():F2}"); Console.WriteLine($"最大值: {amountCol.Max():F2}"); // 分位数:先排序再取索引 var sorted = amountCol.OrderBy(x => x).ToArray(); double Percentile(double[] data, double p) { var index = (data.Length - 1) * p; var lower = (int)Math.Floor(index); var upper = (int)Math.Ceiling(index); if (lower == upper) return data[lower]; return data[lower] + (data[upper] - data[lower]) * (index - lower); } Console.WriteLine($"P50: {Percentile(sorted, 0.50):F2}"); Console.WriteLine($"P90: {Percentile(sorted, 0.90):F2}"); Console.WriteLine($"P99: {Percentile(sorted, 0.99):F2}");分位数的计算逻辑是线性插值:先按(n-1)*p算出位置,如果落在两个索引之间就做插值。这个算法和Excel的PERCENTILE.INC一致,和Pandas的默认行为也一致。参数p取0到1之间,P90就是0.90。
这里有个性能提醒:OrderBy会复制整个数组并排序,如果数据量很大(比如上千万行),排序开销不可忽略。我的做法是如果只需要少数几个分位数,可以用快速选择算法(QuickSelect),平均O(n)而不是O(n log n)。但大多数业务场景数据量在百万级以下,直接排序就够了。
4.2 分组聚合:GroupBy加聚合函数的组合拳
分组聚合是数据分析里最高频的操作。MDA的GroupBy用法和LINQ类似,但返回的是分组后的DataFrame,需要再对每组做聚合。
// 按Status分组,计算每组的订单数和总金额 var grouped = df.GroupBy("Status"); var result = new DataFrame(); var statusList = new List<string>(); var countList = new List<int>(); var sumList = new List<double>(); foreach (var group in grouped) { var status = group["Status"][0]?.ToString(); var count = group.Rows.Count; var sum = (group["TotalAmount"] as DoubleDataFrameColumn).Sum(); statusList.Add(status); countList.Add((int)count); sumList.Add(sum); } result.Columns.Add(new StringDataFrameColumn("Status", statusList)); result.Columns.Add(new Int32DataFrameColumn("OrderCount", countList)); result.Columns.Add(new DoubleDataFrameColumn("TotalAmount", sumList)); Console.WriteLine(result);这段代码的逻辑是:GroupBy返回一个可枚举的分组集合,每个group本身是一个DataFrame。遍历每个分组,取分组键(Status列的第一个值)、行数、金额列的和,然后手动构建结果DataFrame。MDA没有Pandas的agg那种一行搞定的语法,所以需要手动拼。
如果你觉得手动拼太啰嗦,可以用LINQ先做聚合再构造DataFrame,代码会更紧凑。
var rows = df.Rows.Select(row => new { Status = row["Status"]?.ToString(), Amount = Convert.ToDouble(row["TotalAmount"]) }); var agg = rows.GroupBy(x => x.Status) .Select(g => new { Status = g.Key, OrderCount = g.Count(), TotalAmount = g.Sum(x => x.Amount) }) .OrderByDescending(x => x.TotalAmount) .ToList(); foreach (var item in agg) { Console.WriteLine($"{item.Status}: {item.OrderCount}单, {item.TotalAmount:F2}元"); }这种写法用LINQ的GroupBy和Select做聚合,最后输出一个匿名类型列表。优点是代码短、可读性好;缺点是没有生成DataFrame,如果你后续还要做DataFrame操作就不方便。我的选择是:如果聚合结果直接输出报表,用LINQ;如果聚合后还要继续做DataFrame运算,用MDA的GroupBy。
4.3 时间序列聚合:按天、按周、按月汇总
时间序列是业务分析里另一个高频需求。假设你要看每天的订单金额趋势,核心是把时间戳截断到天,再按天分组。
// 假设df里有一列OrderDate是DateTime类型 var dateCol = df["OrderDate"] as DateTimeDataFrameColumn; var amountCol = df["TotalAmount"] as DoubleDataFrameColumn; // 按天聚合 var dailyStats = new Dictionary<DateTime, (int Count, double Sum)>(); for (long i = 0; i < df.Rows.Count; i++) { var date = dateCol[i].Date; // 截断到天 var amount = amountCol[i]; if (dailyStats.ContainsKey(date)) { var (c, s) = dailyStats[date]; dailyStats[date] = (c + 1, s + amount); } else { dailyStats[date] = (1, amount); } } // 输出按日期排序的结果 foreach (var kv in dailyStats.OrderBy(x => x.Key)) { Console.WriteLine($"{kv.Key:yyyy-MM-dd}: {kv.Value.Count}单, {kv.Value.Sum:F2}元"); }按周聚合的话,需要先算出每个日期属于哪一周。ISO 8601标准里,一周从周一开始,第一周是包含至少4天的那一周。C#里可以用System.Globalization.ISOWeek。
using System.Globalization; // 按ISO周聚合 var weeklyStats = new Dictionary<(int Year, int Week), double>(); for (long i = 0; i < df.Rows.Count; i++) { var date = dateCol[i]; var week = ISOWeek.GetWeekOfYear(date); var year = ISOWeek.GetYear(date); var key = (year, week); weeklyStats[key] = weeklyStats.GetValueOrDefault(key) + amountCol[i]; } foreach (var kv in weeklyStats.OrderBy(x => x.Key.Year).ThenBy(x => x.Key.Week)) { Console.WriteLine($"{kv.Key.Year}-W{kv.Key.Week:D2}: {kv.Value:F2}元"); }按月聚合最简单,直接用date.Year和date.Month做键。这三种聚合的代码结构一样,区别只在键的构造方式。实际项目里我一般会封装一个泛型方法,把键选择器作为参数传进去,避免重复写三遍。
5. 避坑指南:C#数据分析里最容易翻车的五个地方
5.1 坑一:DataFrame列类型不匹配导致静默错误
现象:从CSV读进来的金额列,明明CSV里是数字,但Sum()算出来是0或者报类型转换异常。
原因:TextLoader默认会把无法解析为float的值设为0,而且不报错。如果CSV里金额带了货币符号(比如¥100.00)或者千分位逗号(1,000.00),解析就会失败,但加载器不会告诉你。
解决:读取之后立刻做一次数据校验,检查关键列的分布。我的习惯是写一个ValidateColumn方法,统计非零值比例和解析失败数。
var rawCol = data.GetColumn<string>("TotalAmount"); var failCount = rawCol.Count(s => !double.TryParse(s, out _)); if (failCount > 0) { Console.WriteLine($"警告:TotalAmount列有{failCount}条无法解析"); // 打印前几条失败样本 foreach (var s in rawCol.Where(s => !double.TryParse(s, out _)).Take(5)) Console.WriteLine($" 失败样本: [{s}]"); }5.2 坑二:大数据量下DataFrame内存暴涨
现象:处理一个500MB的CSV文件,程序内存占用冲到4GB以上,甚至OOM。
原因:MDA的DataFrame是列式存储,每列单独分配数组。对于字符串列,每个字符串都是独立对象,开销远大于原始文本。加上CreateEnumerable如果设了reuseRowObject: false,每次迭代都会创建新对象,GC压力很大。
解决:三个措施。第一,只加载需要的列,不要SELECT *。第二,字符串列如果基数低(比如状态字段只有几种值),用CategoricalDataFrameColumn或者手动编码成整数。第三,分批处理,每批处理完手动调GC.Collect()(虽然不优雅,但有效)。
// 只加载需要的列:在TextLoader里通过LoadColumn控制 // 如果CSV列很多,可以先用StreamReader读表头,确定需要的列索引 // 然后只定义对应的属性5.3 坑三:GroupBy之后列名丢失
现象:df.GroupBy("Status")之后,遍历分组时用group["Status"]取列,返回null。
原因:MDA的GroupBy返回的分组DataFrame,分组键列可能被重命名或移除,具体行为取决于版本。在某些版本里,分组键列不在分组结果里。
解决:不要依赖分组结果里的键列,而是从分组对象本身取键。如果MDA的API不提供,就在GroupBy之前把键列复制一份到其他列名。
// 保险做法:先复制一份键列 df.Columns.Add(df["Status"].Clone()); df.Columns[df.Columns.Count - 1].SetName("StatusKey"); var grouped = df.GroupBy("Status"); foreach (var group in grouped) { // 用StatusKey而不是Status var status = group["StatusKey"][0]?.ToString(); // ... }5.4 坑四:DateTime解析的时区和格式问题
现象:从数据库读出来的DateTime,在DataFrame里显示的时间比预期少了8小时。
原因:SQL Server的datetime类型没有时区信息,SqlDataAdapter填充到DataTable时按本地时区处理,但转成DataFrame后如果序列化或比较,可能按UTC处理。
解决:统一用DateTimeOffset或者在读取时就指定DateTimeKind。我的做法是在SQL查询里用GETDATE()的地方改成GETUTCDATE(),然后在C#里统一按UTC处理,展示时再转本地时区。
// 读取时指定Kind var dt = DateTime.SpecifyKind(row.Field<DateTime>("OrderDate"), DateTimeKind.Utc);5.5 坑五:MDA版本升级导致的API断裂
现象:项目从MDA 0.19升级到0.21后,原来能跑的GroupBy代码编译报错。
原因:MDA在1.0之前API不稳定,GroupBy的返回类型和DataFrameColumn的某些方法签名在版本间有变化。
解决:锁定版本,在.csproj里用精确版本号而不是浮动版本。升级前先跑一遍回归测试。
<PackageReference Include="Microsoft.Data.Analysis" Version="0.21.1" />注意:MDA目前仍在演进中,生产项目建议锁定小版本,升级时留出回归测试时间。
6. 进阶技巧:把分析结果导出和验证的实用方法
分析做完之后,结果要落地。最常见的两种输出是CSV和Excel。CSV用StreamWriter手写就行,Excel可以用ClosedXML库。但我想讲的是一个更实用的技巧:用单元测试验证分析逻辑。
数据分析代码最容易出的问题不是编译错误,而是逻辑错误——统计算错了、分组漏了、过滤条件写反了。这些错误在控制台输出里很难发现,但用单元测试可以精确捕获。
using Xunit; public class AnalysisTests { [Fact] public void GroupByStatus_ShouldReturnCorrectCounts() { // 构造测试数据 var df = new DataFrame( new StringDataFrameColumn("Status", new[] { "A", "B", "A", "A" }), new DoubleDataFrameColumn("Amount", new[] { 10.0, 20.0, 30.0, 40.0 }) ); // 执行分组 var result = Analyze.GroupByStatus(df); // 验证:A组应有3条,总额80;B组1条,总额20 Assert.Equal(3, result.First(r => r.Status == "A").Count); Assert.Equal(80.0, result.First(r => r.Status == "A").Total); Assert.Equal(1, result.First(r => r.Status == "B").Count); } }这个测试的思路是:用固定的小数据集,手动算出预期结果,然后断言代码输出是否匹配。参数方面,测试数据要覆盖边界情况——空值、重复值、极端值。我一般会为每个统计方法写至少三个测试:正常数据、空数据、含异常值的数据。
另一个实用技巧是用BenchmarkDotNet测性能。数据分析代码的性能瓶颈往往在你不注意的地方,比如某个OrderBy被调用了多次,或者字符串拼接在循环里。BenchmarkDotNet能给出精确的耗时和内存分配。
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; [MemoryDiagnoser] public class SortBenchmark { private double[] _data; [GlobalSetup] public void Setup() { var rnd = new Random(42); _data = Enumerable.Range(0, 1_000_000).Select(_ => rnd.NextDouble() * 1000).ToArray(); } [Benchmark] public double[] SortWithLinq() => _data.OrderBy(x => x).ToArray(); [Benchmark] public double[] SortWithArray() { var copy = (double[])_data.Clone(); Array.Sort(copy); return copy; } } // 在Main里调用 BenchmarkRunner.Run<SortBenchmark>();这个基准测试对比LINQ的OrderBy和Array.Sort。在我的机器上,百万级double数组,Array.Sort比LINQ快大约3倍,内存分配少一个数量级。所以如果你的分析代码里有大量排序,优先用Array.Sort。
最后说一个我自己的习惯:每次分析任务开始前,先用Excel或Pandas手动算一遍小样本的预期结果,把这个结果作为“黄金标准”,然后用C#代码去逼近它。如果C#算出来的和黄金标准不一致,先怀疑自己的代码,而不是怀疑数据。这个习惯帮我省下了大量排查时间——数据分析的玄学在于,你以为错的是数据,其实错的是你的GroupBy键选错了。
希望帮到你。
本文还有配套的精品资源,点击获取