☰
Pandas平行坐标实战:高维数据可视化与维度排序
2026/10/27 12:59:55 网站建设 项目流程

1. 先搞清楚:平行坐标到底解决什么可视化难题

手上有张宽表,六个指标、三类客户分层,领导问"这三类人到底差在哪几个维度上"。你把散点图两两组合画了 15 张,翻到第五张就已经记不清前四张长什么样了;你试过雷达图,指标一多轴就被挤成一朵菊花,而且雷达图的面积受维度顺序影响,很容易给出误导性结论;你也试过先做降维再散点,但降维之后每一维都变成了"主成分 1、主成分 2",业务方看不懂,你也没法回答"到底是哪个指标拉开的差距"。Pandas 多维数据的平行坐标可视化,就是用来填这个坑的:把 N 个维度变成 N 根平行竖轴,每一个样本变成一条从左到右穿过所有竖轴的折线,类别用颜色区分,于是一整张宽表在二维平面上被"摊开"成一片可以肉眼扫读的线束。

它解决的问题很具体:在保留原始维度语义的前提下,一次性观察高维分布。散点矩阵是 O(n²) 张图,平行坐标是 1 张图;降维方法牺牲了可解释性,平行坐标一根轴就是一个业务指标,指哪打哪。适合谁用?做用户分层、设备工况诊断、模型特征筛查、A/B 实验多维指标对比、金融风控变量检查的数据分析同学,以及任何手里握着 5 到 20 个数值列、想快速判断"哪些维度有区分力、哪些样本是异常点"的人。Pandas 自带了一个开箱即用的parallel_coordinates,一行代码能出版面,但真正难的地方不在那一行代码,而在画之前的数据整理、维度排序和画之后的读图逻辑——这篇文章就是把这几个环节拆干净。

1.1 高维数据"看不见"的困境与几种常见替代方案的取舍

人眼能同时分辨的视觉通道是有限的:位置最准,长度次之,角度和面积最差。二维散点用满了 x、y 两个位置通道,最多再加颜色和大小两个通道,也就是四维左右到顶了。数据一旦到六维以上,就必须靠"分批看"或者"降维压缩"来妥协。散点矩阵(scatter matrix)是把每两个维度画一张,好处是直观,坏处是维度一多图就爆炸:10 个维度就是 45 张子图,而且每张子图只能同时看两个维度,维度之间的联合模式被切碎了。热力图(相关性矩阵)能把所有维度塞进一张图,但它只保留二阶统计量,你看到的是相关系数,看不到分布在每个维度上的形状,也看不到某个类别在某个维度上的聚集带。

雷达图看起来最像"多维",但它有个致命缺陷:围成的面积和形状依赖于轴的排列顺序,同样一份数据换个顺序就能"显得"更均衡或者更偏斜,汇报时很容易被人质疑。而且雷达图的轴是从圆心向外辐射的,同一根轴上的尺度变化被角度稀释,读数值很不准。

平行坐标的思路完全不同:它不做任何压缩,N 维就是 N 根轴,样本是折线。折线的斜率成了新的视觉通道——斜率越大,说明这个样本在这一对相邻维度之间变化越剧烈;两条折线在某段平行,说明这两个样本在这两个维度上的相对表现一致。这个通道是散点矩阵和热力图都给不了的。代价是:折线一多就糊,维度一多就密,维度顺序会直接影响可读性。所以平行坐标不是"更高级的图",它是另一种取舍——用密度和顺序敏感性换维度完整性和趋势可读性。

我在实际项目里的判断标准很粗暴:维度在 4 到 15 之间、样本量在几百到几万之间、有明确的类别列,就用平行坐标;维度只有 2 到 3 个,老老实实散点;维度超过 20 个,先做特征筛选或者先聚成几个指标组,别硬画,画出来是一团毛线,谁看谁头疼。

1.2 读图规则:三种线型语言和四个观察位置

平行坐标有自己的一套"语法",不掌握这套语法,图就是一张装饰画。我把常用的读法归成三类线型语言:相邻两轴上折线平行,意味着这两个维度正相关,一个涨另一个也涨;相邻两轴上折线大范围交叉成 X 形,意味着负相关,一个涨另一个跌;相邻两轴上折线随机散开、没有明显走向,意味着这两个维度基本无关,属于噪声维度,可以考虑从图里踢出去。

除了线型,还要看四个位置。第一看轴上的聚散:如果某一根轴上所有线都挤在一个窄区间,说明这一维在全样本上区分度低;如果线在这根轴上拉得很开,说明这一维有信息量。第二看颜色带的分离度:如果某一根轴上,红线和蓝线明显分成上下两坨,那这根轴就是区分这两个类别的关键指标,业务解释可以直接从这里切进去。第三看跨轴的走向:某一类样本从轴 3 到轴 4 陡然下降,这类样本就在这个指标上有共同短板,是可以直接写进结论的发现。第四看孤立的折线:某一条线在所有轴上都不跟着大部队走,要么是数据错误,要么是真实的极端样本,两种情况都值得单独拎出来看。

有个容易踩的误区:很多人以为"线交叉多就是数据乱"。不是的。交叉多说明这两个维度负相关强,恰恰是信息量大的表现,只是视觉上不友好。真正该警惕的是"什么都看不出来"——所有线均匀地铺满整个画布,那说明这些维度对这个类别划分没有贡献,要么换维度,要么换分组方式。

提示:平行坐标里每一根轴的上下端点都是该维度的最小值和最大值,不同轴之间的刻度不可比。哪怕两条线在轴 2 上看起来"一样高",它们的原始数值也可能差着数量级。读绝对值要看轴上标注,读相对趋势才看折线斜率。

1.3 适用边界:什么场景该上,什么场景别硬来

先说该上的。第一类是类别可分性诊断:手里有一批已经打好标签的样本(用户分层、设备状态、实验分组),想知道这些标签在哪些维度上是"真分开"的,哪些维度纯属摆设。平行坐标一眼就能看出哪根轴上颜色分层最清楚,比跑一遍 ANOVA 更直观,而且能顺带看到分布形状是不是双峰。第二类是异常样本定位:折线在某一根轴上突然窜出去的样本,基本可以锁定为需要复核的对象。第三类是模型特征筛查:建模前看看几个候选特征在不同标签下的走势,能提前发现"这个特征压根没区分度"或者"这个特征和那个特征高度共线"。

不该硬上的场景也得说清楚。样本量上万而类别又很细的时候,平行坐标会退化成一片半透明的噪点,这时候必须先抽样或者先聚合。维度超过 20 个的时候,轴的间距会被压到几乎看不见,折线的横跨距离太短,斜率信息完全丢失,这时候正确做法是先做维度分组或者先跑一遍特征重要性排序再挑前 8 到 12 个。还有一种情况:所有维度都是同一量纲、数值范围也接近,比如六个都是 0 到 1 的比例指标——这种数据画平行坐标会突然变得非常好读,因为归一化不会扭曲任何东西。反过来说,如果维度里混着"金额(万级)"和"比率(0 到 1)"这种量纲差异巨大的列,就一定要做归一化处理,否则比率列的折线会完全贴底,视觉上等于消失。

2. Pandas 原生 parallel_coordinates 的底层逻辑与硬约束

pandas.plotting.parallel_coordinates是这套流程里最省事的一环,一行代码出图,但它有一堆隐式行为,不搞清楚会浪费很多时间在"为什么我的图跟别人不一样"上。函数签名大致是这样:

from pandas.plotting import parallel_coordinates parallel_coordinates( frame, class_column, cols=None, ax=None, color=None, use_columns=False, xticks=None, colormap=None, axvlines=True, axvlines_kwds=None, sort_labels=False, **kwargs )

核心参数只有三个值得记:class_column是类别列名,用来决定颜色分组,必须存在且只传一个;cols是要参与绘图的数值列清单,不传就默认用"去掉类别列之后的所有列";color和colormap二选一,类别少的时候直接给颜色列表更可控,类别多的时候用 colormap 让它自动循环。剩下的xticks、axvlines、sort_labels都是版式微调,日常用得不多。

2.1 参数背后真正需要注意的三个行为

第一个行为:它会自动把类别列从数值矩阵里剔除。也就是说你把class_column传进去之后,这一列不会再作为一根轴出现。这个设计是对的,但容易让人误以为"我的数据里所有列都会被画出来"。我见过有人把 ID 列留在表里,结果图上多出一根毫无意义的轴,全是噪音。

第二个行为:它会自动加竖直参考线和轴标签,轴标签直接用 DataFrame 的列名。列名如果是中文,而且比较长,标签会互相叠在一起,必须自己手动转一下角度:

ax = parallel_coordinates(df, "分层", colormap="tab10", alpha=0.3) ax.set_xticklabels(ax.get_xticklabels(), rotation=20, ha="right")

第三个行为:sort_labels控制图例顺序。默认按照类别在数据里第一次出现的顺序排,如果想让图例按业务含义固定顺序(比如"高价值 → 成长型 → 流失风险"),要么提前把类别列转成有序的 Categorical,要么打开sort_labels=True按字典序排。后者对中文不友好,我一般用前者。

2.2 它偷偷做的归一化,以及常量列带来的隐患

这是最容易出问题的地方:parallel_coordinates在内部对去掉类别列之后的数值矩阵逐列做 min-max 线性拉伸,把每一列都压到 0 到 1 的区间再画。这个设计是必要的——不归一化的话,量纲大的列会把整张图撑爆,量纲小的列集体贴底。但它带来两个后果,必须提前知道。

后果一:图上的纵坐标不再是原始数值。你在图上看到某条线跑到 0.9 的位置,只代表它在那个维度上接近该维度的最大值,不代表它真的"得了 0.9 分"。汇报时必须自己把每个维度上的原始最小值和最大值标注出来,否则听众会误读。我的做法是在图下方加一行小字,写上每根轴的min ~ max,或者在 PPT 里单独放一张对照表。

后果二:常量列会出问题。如果某一列所有样本的值都一样,max - min等于 0,归一化时就是除以零,结果变成 NaN 或者无穷,那根轴上所有折线全部断掉或者飞出去。我在一次内部数据上就栽过:某个指标的取值全部是同一个默认值,图上看那根轴空空如也,排查了半天才发现是常量列。处理办法很简单,画图前先做一次体检:

num_cols = df.select_dtypes(include="number").columns.drop("分层") const_cols = [c for c in num_cols if df[c].nunique(dropna=True) <= 1] print("常量列,必须剔除:", const_cols) plot_cols = [c for c in num_cols if c not in const_cols]

2.3 五个必须提前处理的硬约束

第一,类别列不能是浮点型。如果标签本身是 0/1/2 这种数字编码,函数会把它当类别处理,这没问题,但如果标签列里混了 NaN,NaN 会被当成一个独立类别,图上多出一组颜色诡异的线。第二,参与绘图的列必须全是数值型。表里只要留了一列字符串,比如"城市""渠道",整个函数直接抛错。第三,缺失值不会报错但会让线断掉,视觉上你只会觉得"这条线怎么少了一段",很难第一时间反应过来是 NaN。第四,样本量没有上限但也几乎没有下限保护,几万行的时候绘图会明显卡顿,因为它是逐行调用 plot 的。第五,类别数量决定了颜色循环是否够用,超过 10 个类别的时候默认 colormap 会给出一堆相近的颜色,肉眼根本分不开。

我的标准前置检查代码基本固定成这个模板:

import numpy as np import pandas as pd def prepare_for_parallel(df, label_col, cols, na_fill="未知"): out = df.copy() out[label_col] = out[label_col].astype(str).fillna(na_fill) out[cols] = out[cols].apply(pd.to_numeric, errors="coerce") out = out.dropna(subset=cols) # 或者按业务含义填充,见下一章 return out

这几行看着平淡,但它挡住了后面 80% 的诡异现象。

注意:如果你用的是较新的 pandas 版本,parallel_coordinates仍然可用,但它的绘图能力本质上就是 matplotlib 的折线,样式控制有限。需要精细控制的时候,直接手写往往比调参数更快,第 5 章会给出完整实现。

3. 数据准备:把宽表整理成"可画"的形态

平行坐标对数据形态的要求其实很宽松——宽表,一行一个样本,一列一个维度,有一个类别列,齐活。但"能画"和"画出来有信息量"是两回事,中间隔着缺失值、异常值、量纲和分布形态四道关。这一章按顺序把这四道关过一遍,每一步都给出可以直接套用的代码和判断依据。

3.1 类型清洗与缺失值策略:三种情形三种处理

缺失值在平行坐标里的表现是"线断了一截",因为 matplotlib 遇到 NaN 会中断折线。这不美观,但更严重的问题是它会误导判断:一条从轴 2 断到轴 4 的线,你可能以为是绘图 bug,其实那是真实缺失。

处理策略取决于缺失比例和缺失机制。缺失比例低于 1%,而且集中在个别列上,我直接整行删除,代价可以忽略。缺失比例在 1% 到 15% 之间,看这一维的重要性:如果是关键维度,用该类别内的中位数填充,因为平行坐标关心的是类别间的相对走势,按类别内填充不会破坏分层结构;如果是边缘维度,也可以考虑直接剔除这一列。缺失比例超过 30%,填充就是在编数据了,正确做法是把缺失本身当成一个类别来观察——单独给缺失样本一个颜色,看这些样本在其他维度上是否也异常。很多时候"为什么缺失"本身就是最有价值的发现。

# 情形一:按类别内中位数填充,保留分层结构 for c in cols: df[c] = df.groupby("分层")[c].transform(lambda s: s.fillna(s.median())) # 情形二:缺失本身作为信号,补一个标记列 df["停留时长_缺失"] = df["停留时长"].isna().astype(int)

第二种做法在风控和用户行为分析里特别常见:某个指标为空,往往意味着用户压根没走到那一步,空值本身就是行为信号,填一个中位数反而把信息抹掉了。

3.2 异常值裁剪与分布变换:别让一条线毁掉整张图

平行坐标对异常值极其敏感,因为归一化用的是最小值和最大值。只要有一个离群点,整根轴的压缩比就被它拉偏,剩下 99% 的样本全挤在轴的一小段里,图就废了。这是新手最容易忽略的一点:你的图看起来"所有线都挤在下面",不一定是数据分布就这样,很可能是某个极端值在捣鬼。

裁剪是最省事的办法,用 1% 和 99% 分位数做缩尾:

def winsorize(df, cols, lower=0.01, upper=0.99): out = df.copy() for c in cols: lo, hi = out[c].quantile([lower, upper]) out[c] = out[c].clip(lo, hi) return out

缩尾之后别忘了在图上或者报告里说明"数值已按 1%/99% 分位裁剪",这是基本的诚实。另一个选择是把超出范围的值单独标成一个颜色,不裁剪,让它"飞"出去——这么做的前提是你的读者知道那几条线是异常样本,而且你希望他们看到。

分布严重右偏的列(比如金额、时长这类典型的长尾指标)还应该考虑对数变换:

skewed = [c for c in cols if df[c].skew() > 2] df[skewed] = np.log1p(df[skewed])

对数变换之后再做 min-max,轴上的分布会舒展很多,线束的层次感立刻出来。判断要不要变换的标准就是偏度,大于 2 基本可以考虑,大于 5 强烈建议。注意取对数之后原始最小值的解释会变化,报告里要写清楚。

3.3 派生维度与聚合粒度:先想清楚"一行代表什么"

这一步是最容易被跳过、但影响最大的。平行坐标里一条线就是一个观测单位,你的观测单位是用户、是订单、是设备、还是"某天的某台设备"?不同粒度画出来的图完全不同。如果原始数据是订单级,而标签是用户级,就必须先聚合到用户级——否则同一个用户在图上会出现几十条线,把图糊死。

聚合时用什么统计量,取决于你想讲什么故事。想讲"典型水平",用均值;想讲"稳定程度",用标准差;想讲"极端表现",用最大值。我经常在一次分析里同时画两张图:一张是各维度均值,看整体差异;一张是各维度标准差,看哪类用户的波动更大。两张图对照着看,结论会立体很多。

user_level = ( orders.groupby(["user_id", "分层"]) .agg(客单价=("amount", "mean"), 下单频次=("order_id", "count"), 退货率=("is_refund", "mean"), 停留时长=("duration", "mean")) .reset_index() )

派生维度也有讲究。原始指标有时区分度不高,但两两相除或者相减之后就非常犀利,比如"客单价 ÷ 停留时长"能刻画单位时间的转化效率,"活跃天数 ÷ 访问间隔"能刻画行为节奏。这类派生指标我一般会先算出来,扔进候选维度池,让第 4 章的排序算法去筛。但要小心:派生维度之间往往高度相关,一起扔进平行坐标会让图里出现大段平行的重复信息,反而占用了宝贵的横向空间。

4. 维度排序:决定这张图好不好读的关键一步

平行坐标的横向空间是有限的。8 根轴在 12 英寸宽的画布上,每根轴间隔不到 1.5 英寸,折线的斜率很容易被压得看不出方向。更麻烦的是,维度顺序直接决定了你能看到哪些关系——平行坐标只能显示"相邻两轴"之间的相关性,不相邻的两维之间的关系被中间的轴挡住了。所以排序不是审美问题,是信息能不能被读出来的问题。很多人画完觉得"图挺好看但说不出啥",八成是维度顺序随手排的。

4.1 相关性贪心排序:让相邻维度尽量相关

最朴素的思路是:把相关性强的维度排在一起,这样相邻轴之间就会出现大量平行线,图看起来"顺",而且能一眼读出正相关结构。实现上就是算一个 Spearman 相关矩阵,然后从某个起点开始,每次跳到与当前轴相关性绝对值最大的未访问维度,本质是一个最近邻的贪心式路径构造。

def greedy_dim_order(df, cols, start=None, method="spearman"): corr = df[cols].corr(method=method).abs() order = [start or cols[0]] rest = set(cols) - set(order) while rest: last = order[-1] nxt = max(rest, key=lambda c: corr.loc[last, c]) order.append(nxt) rest.remove(nxt) return order

这个算法的效果立竿见影:原来随机排的时候图上一片交叉,重排之后大段大段的平行线出现,负相关的维度被安排成陡峭的交叉,视觉上立刻有了"结构感"。代价是它可能把某两个强相关但方向相反的维度拆开,所以我在跑完之后还会人工看一眼相关矩阵,把明显负相关的一对调到相邻位置——负相关在平行坐标里表现为 X 形交叉,也是一种可读的模式,甚至比平行的信息量更大。

提示:Spearman 而不是 Pearson。平行坐标关心的是单调趋势,不是线性关系;而且 Spearman 对异常值不敏感,排序稳定性更好。

4.2 Fisher 判别比:先挑出"真能分开类别"的维度

排序之前还有一步筛选:一张图放 20 根轴是没法看的,得先砍到 8 到 12 根。砍谁留谁不能凭感觉,我用的是每个维度对类别划分的判别力,本质上是 Fisher 判别比的思路——类间方差除以类内方差,值越大说明这个维度上类别分得越开。

def fisher_score(df, cols, label_col): overall = df[cols].mean() sb = pd.Series(0.0, index=cols) sw = pd.Series(0.0, index=cols) for _, g in df.groupby(label_col): n = len(g) sb += n * (g[cols].mean() - overall) ** 2 sw += ((g[cols] - g[cols].mean()) ** 2).sum() return (sb / sw).sort_values(ascending=False) rank = fisher_score(df, plot_cols, "分层") top_cols = rank.head(10).index.tolist()

这个分数的好处是有业务解释:分数高的维度就是"拉开类别差距的维度",你可以在报告里直接写"退货率和访问间隔是区分三类用户最关键的两个指标",而不是只有一句抽象的主成分解释。分数低的维度也不是没价值,它们可能在其他分析角度上有用,只是在这张平行坐标里贡献不大,先请出去。

我一般会做一个"筛掉前后的对比":先画一张全维度的,再画一张只留 Top 8 的,把两张并排放在分析文档里。前者说明"我看过全部数据",后者说明"我做了取舍",这个动作在评审时很有说服力。

4.3 业务逻辑排序与人工干预:算法不够,人来补

纯统计排序有个毛病:它不理解业务含义。有时候两个维度在数值上不相关,但业务上必须挨着放才好讲故事,比如"活跃天数"和"访问间隔"其实是一体两面,放在一起读者能立刻懂,分开了就得解释半天。

所以我的实际做法是:先用 Fisher 分数筛维度,再用贪心相关排序出个草稿,然后人工调整三处。第一,把同一业务模块的维度放到一起,形成"区块",比如流量类指标一块、交易类指标一块、服务类指标一块。第二,把最关键的维度放在最左和最右两端,因为这两根轴上的线最"散",最容易读出分布形状,中间位置反而被压缩。第三,把方向相反的维度(一个越大越好、一个越小越好)故意排成相邻,让它们呈现 X 形交叉,这样"好样本"的折线会在中间形成收口,视觉上非常醒目。

这套"算法排序 + 人工干预"的组合,比纯粹依赖任何一边效果都好。我做过一次对比,同一份数据,随机顺序、纯算法顺序、算法加人工三种版本的图给同事看,让他们在 30 秒内说出"哪几个指标最能区分三类用户",只有第三种版本大部分人都说对了。

5. 从静态到交互:三种不同的落地实现

选哪个库,取决于你的用途。快速探索阶段用 pandas 一行出图;要做精细版式、要打印、要嵌进报告就用 matplotlib 手写;要给业务方看、希望他们自己悬停查看具体数值,那就上交互式。这三种实现我都在生产里用过,下面把代码和取舍写清楚。

5.1 Pandas 一行出图:探索阶段的正确姿势

探索阶段的核心诉求是"快",不追求好看,只求三秒内看到大致形态。这个阶段我会做两件事:加大透明度、抽一部分样本。

import matplotlib.pyplot as plt from pandas.plotting import parallel_coordinates plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", "SimHei"] plt.rcParams["axes.unicode_minus"] = False sample = df.sample(min(len(df), 1500), random_state=42) fig, ax = plt.subplots(figsize=(12, 5.5), dpi=120) parallel_coordinates( sample, "分层", cols=top_cols, colormap="tab10", alpha=0.25, axvlines=True, ax=ax ) ax.set_xticklabels(ax.get_xticklabels(), rotation=18, ha="right") ax.set_ylabel("归一化后的相对位置") ax.legend(loc="upper right", frameon=False, fontsize=9) plt.tight_layout() plt.show()

alpha是关键参数。0.8 的时候前 20 条线就把后面全挡住,0.2 到 0.3 能看到"密度",哪里的线扎堆、哪里的线稀疏,一目了然。figsize建议宽一点,12 英寸比 8 英寸好读得多,因为轴的间距变大了,斜率信息才留得住。

这个版本的局限也很明显:三万行以上明显卡顿,样式调不动,图例遮挡数据区没办法自动避让。所以我只用它做第一轮试探,确定维度顺序和大致结论再换手写版本。

5.2 Matplotlib 手写 + LineCollection:性能与可控性兼得

parallel_coordinates是逐行调用 plot 的,几千行还行,几万行就明显变慢。真正大批量绘制应该用LineCollection:把所有线段打包成一个集合对象一次性提交给 matplotlib,渲染开销大幅下降。我做过对比,同样五万条线,逐行绘制要等好几十秒,LineCollection 一两秒就出图,量级差距非常明显。

import numpy as np import matplotlib.pyplot as plt from matplotlib.collections import LineCollection def parallel_lc(df, cols, label_col, palette=None, alpha=0.3, lw=0.9, figsize=(12.5, 5.5), ax=None): data = df[cols].astype(float) lo, hi = data.min(), data.max() span = (hi - lo).replace(0, np.nan) norm = ((data - lo) / span).fillna(0.5) # 常量列退化为中线 x = np.arange(len(cols), dtype=float) ax = ax or plt.subplots(figsize=figsize, dpi=120)[1] labels = df[label_col].astype(str) cats = sorted(labels.unique()) palette = palette or {c: plt.cm.tab10(i % 10) for i, c in enumerate(cats)} for cat in cats: sub = norm.loc[labels == cat].to_numpy() if len(sub) == 0: continue pts = np.stack([np.tile(x, (len(sub), 1)), sub], axis=2) # (m, n, 2) segs = np.stack([pts[:, :-1, :], pts[:, 1:, :]], axis=2).reshape(-1, 2, 2) ax.add_collection(LineCollection( segs, colors=[palette[cat]], alpha=alpha, linewidths=lw)) ax.set_xlim(-0.25, len(cols) - 0.75) ax.set_ylim(-0.05, 1.05) ax.set_xticks(x) ax.set_xticklabels(cols, rotation=18, ha="right") ax.set_yticks([0, 0.5, 1]) ax.grid(axis="x", color="0.85", linewidth=0.8) ax.set_axisbelow(True) for s in ("top", "right"): ax.spines[s].set_visible(False) # 把每根轴的原始取值范围标在图下方 for i, c in enumerate(cols): ax.annotate(f"{lo[c]:.1f}~{hi[c]:.1f}", xy=(i, -0.09), ha="center", va="top", fontsize=7.5, color="0.4") return ax

这段代码有三个设计点值得说明。span.replace(0, np.nan)处理了常量列,退化成中线而不是产生 NaN。pts的构造方式把每条样本线转成(m, n, 2)的点序列,再拆成线段数组,这是 LineCollection 需要的格式,比循环 append 快得多。轴下方的annotate把每根轴的原始取值范围标了出来,解决"归一化之后读不出绝对值"的问题,这是我在汇报时被问过最多的一件事,后来就固定加上了。

参数选择上给几个经验值:alpha在样本少于 500 时用 0.4 到 0.5,500 到 5000 用 0.25 到 0.35,超过 5000 用 0.08 到 0.15。lw用 0.8 到 1.0,再粗就糊了,再细在高分屏上看不见。

5.3 Plotly 交互版:汇报场景的杀手锏

给业务方看的时候,静态图有个天然劣势:他们想看某条线,你得手动去查。交互版能让他们自己悬停,鼠标放上去就显示这个样本在各维度上的具体值,说服力完全不一样。Plotly Express 的parallel_coordinates就是干这个的。

import plotly.express as px fig = px.parallel_coordinates( df, dimensions=top_cols, color="退货率", labels={c: c for c in top_cols}, color_continuous_scale=px.colors.diverging.Tealrose, color_continuous_midpoint=float(df["退货率"].median()), ) fig.update_layout(margin=dict(l=60, r=60, t=50, b=60), height=520) fig.show()

color传数值列时是连续渐变色,适合表达"某个指标的强度沿折线如何变化";传类别列时会自动切成离散色块,适合做分组对比。注意color用的列如果也在dimensions里,那根轴会同时承担"着色依据"的角色,这时候轴的拖动筛选和颜色会联动,交互感很强,但也容易让读者困惑,我的做法是把着色列单独拎出来,不重复放进维度列表。

Plotly 版本有个限制要提前说:数据量超过一万行,浏览器渲染会明显掉帧,拖动轴筛选时的卡顿尤其明显。解决办法和静态版一样,先聚合或者先抽样。另外它导出的静态图(write_image)需要额外的渲染引擎支持,如果只是要一张 PNG 嵌进文档,直接用 matplotlib 版本更省事。

6. 类别多、线太密怎么办:五种降噪手段

平行坐标最常见的失败形态是"一坨"。类别有十几个,每个类别几百条线,全部半透明叠上去,结果就是一个看不出任何结构的彩色雾团。这一章给五个具体手段,都是我在真实报告里用过的,按效果从强到弱排。

6.1 分层抽样与密度感知透明

第一步永远是降采样,但要分层抽而不是随机抽。类别不平衡的时候随机抽会让小类别几乎消失,分层抽保证每个类别至少留一定数量,图例才完整。

def stratified_sample(df, label_col, per_class=200, seed=42): return (df.groupby(label_col, group_keys=False) .apply(lambda g: g.sample(min(len(g), per_class), random_state=seed)) .reset_index(drop=True))

每类 200 条是个不错的起点,总样本控制在 1000 到 2000,透明度 0.3 左右,密度感能出来又不至于糊。如果某些类别只有几十条,那就按最小值来,保持各类别视觉权重接近。

6.2 重点类别高亮:灰化背景 + 彩色前景

当类别太多的时候,与其让十几个颜色打架,不如只强调两三个。把所有"非重点"类别统一画成浅灰色,透明度 0.1,重点类别用饱和色、加粗、不透明。这个技巧的威力巨大——人的注意力会自动被彩色吸引,一张图瞬间从"看不过来"变成"一眼抓住"。

focus = ["流失风险"] for cat in cats: color = "#d62728" if cat in focus else "0.82" alpha = 0.75 if cat in focus else 0.12 lw = 1.3 if cat in focus else 0.7 # ... 绘制逻辑同上

配套的图例也要改:灰色部分单独给一个"其他类别"的条目,别让读者以为那些灰线是背景网格。

6.3 分面小图:一次只讲一个类别的故事

类别超过 6 个的时候,我最常用的方案是分面。2×3 或者 3×3 的小图网格,每张小图里所有样本画成灰色背景,只有当前类别画成彩色。这样每个类别都能看清自己的形状,也能和整体分布对比,阅读负担从一个"信息过载的大图"变成"六张各讲一件事的小图"。

fig, axes = plt.subplots(2, 3, figsize=(15, 7), sharey=True, sharex=True) for ax, cat in zip(axes.ravel(), cats): # 背景:全部样本灰色 draw_lines(ax, norm, color="0.85", alpha=0.15) # 前景:当前类别彩色 draw_lines(ax, norm[labels == cat], color="tab:red", alpha=0.6) ax.set_title(str(cat), fontsize=10)

sharex和sharey一定要开,否则各张小图的刻度不一致,对比就失去意义了。这个做法在用户分层的报告里效果特别好,因为每张图讲一个用户群的特征,读者可以逐张看,也可以横向对比。

6.4 维度分组聚合与轴间距调整

如果维度多到挤不下,可以考虑把相关性高的几个维度先求均值合成一个"指标组",比如把四个流量类指标合成一个"流量综合分"。这本质上是降维,但用的是业务含义明确的加权或等权平均,比主成分可解释得多。合成之后轴的数量减少,每根轴之间的间距变大,斜率信息立刻清晰。

另一个纯版式手段是加大画布宽度。12 英寸到 16 英寸,8 根轴之间的间距增加三分之一,可读性提升非常明显。这个改动成本最低,但很多人画完就截图往文档里一贴,图被压缩到 6 英寸宽,什么结构都看不出来了。

6.5 排序 + 排序 + 排序

说三遍是因为它真的最有效。同一份数据,同一批样本,只改维度顺序,图的观感可以从"毛线团"变成"结构分明"。前面第 4 章的排序方法在这里的价值会集中体现。我在一次内部分享里做过极端对比:把顺序完全打乱和用贪心相关排序排过之后的两张图并排展示,问在场的人"哪张更能看出类别差异",二十几个人全部指向排过序的那张。

7. 常见问题与排查实录

下面这张表是我自己攒的速查表,遇到问题基本先来这里对一遍。表格后面挑几个高频的展开说。

现象最可能的原因排查动作解决方式
所有线条颜色一致,图例只有一项类别列全是同一个值,或类别列被当成了数值参与归一化df[label_col].value_counts()检查类别列取值,确认class_column传的是标签列
某根轴上完全没有线该列为常量列,归一化除零df[c].nunique()剔除该列,或用第 5.2 节的fillna(0.5)处理
线条在中间断开存在 NaNdf[cols].isna().sum()填充或整行删除,参考 3.1 节
图上一片糊,看不出结构样本过多、透明度太低、维度顺序随机看样本量和 alpha分层抽样 + alpha 降到 0.2 + 重排维度
中文轴标签变成方框字体未配置检查rcParams指定系统中确实存在的中文字体
报错could not convert string to float数值列里混了字符串或空串df[cols].dtypespd.to_numeric(..., errors="coerce")后处理
折线全部挤在轴的上半部或下半部存在极端离群值拉偏了 min/max看各列分位数分位数缩尾或对数变换
PDF 导出体积几百 MB线条是矢量对象看文件大小savefig(..., rasterized=True)
图例挡住大量数据图例位置固定在右上角观察数据密集区移到空白区或放到图外bbox_to_anchor

7.1 颜色分不开,往往不是配色问题

第一反应是换 colormap,但我遇到的情况里,一半以上其实是类别列本身有问题。比如标签列是浮点数,0.0、1.0、2.0 三个值,函数按每个唯一值分一组,看起来没问题;但如果标签列里有 0.0 和 0.0000001,那就会被当成两个类别,颜色循环立刻错位。还有一种情况:类别列是字符串,但混了首尾空格,"A"和"A "变成两个类别。所以配色出问题,先做一次value_counts()和.str.strip(),比调色板管用。

真正需要换配色的时候,我的原则是:类别少(不超过 5 个)用定性色板,比如 tab10 前几个,色相差异大;类别多的时候改用高亮策略,而不是去找一个能区分 15 种颜色的色板——不存在这样的色板,人眼分辨不了。另外别用红绿对比给色盲同事看,蓝橙、紫黄都是更安全的组合。

7.2 归一化之后数值读不出来,怎么补救

这是平行坐标的固有缺陷,不是 bug。补救方式有三层。第一层,在每根轴的下方标注原始最小值和最大值,第 5.2 节的代码已经实现了。第二层,如果你只关心某几个样本,把它们单独用加粗线标出来,并在旁边直接标注原始数值。第三层,准备一张配套的表格,列出各类别在每个维度上的均值和中位数,图和表对照着看,既能看到形状又能看到数值。

我在实际汇报里发现,图负责讲趋势,表负责给数字,这个分工是最舒服的。硬要把所有数值都塞进图里,图就没法看了。

7.3 大数据量卡顿,先量一下再优化

卡的时候先确认是渲染卡还是数据处理卡。一个快速判断方法:把样本降到 1000 行再画一遍,如果秒出,那就是渲染问题;如果还是很慢,那就是前面的 groupby 或 apply 写得太慢。

渲染问题的解法就那几条:抽样、LineCollection、rasterized=True。数据处理慢的话,重点查 groupby 里的 lambda,transform通常比apply快,能向量化的绝不循环。我见过一段代码用iterrows()逐行算归一化,五万行跑了两分多钟,改成向量化写法之后零点几秒。这种优化不涉及算法,纯粹是别用错 API。

8. 几个我踩过之后才记住的细节

说几个文档里不太会写、但实际会绊人的点。

第一个,别在归一化之后再排序。排序是按维度重排,跟归一化没关系,但如果你先算好了归一化矩阵,再从里面按新顺序取列,一定要注意列名对齐,别用位置索引。我有一次用iloc取列,结果维度顺序和标签顺序错位,图上轴标签和实际数据对不上,看了半天才反应过来,这种错很难靠肉眼发现。

第二个,类别不平衡的时候,透明度会让小类消失。大类八百条线,小类五十条线,同样 alpha=0.2,小类的存在感几乎为零。解决办法是给小类更高的 alpha 或者更粗的线宽,或者干脆用第 6.2 节的高亮法。这不算作弊,可视化的目的是让信息被看见,不是追求视觉等价。

第三个,轴标签旋转角度别超过 30 度。转到 45 度看着整齐,但中文字符旋转之后辨识度下降很多,尤其是字号被压到 8pt 以下的时候。20 度左右配右对齐,是我试过最舒服的组合。

第四个,保存图片时 dpi 和 bbox 都要管。dpi=200配bbox_inches="tight"是报告用的标准组合,前者保证放大不糊,后者把多余白边切掉,图在文档里能占更大比例。如果嵌进 PDF 且线条特别多,加rasterized=True,文件体积能小一个数量级,代价是文字部分仍然矢量、线条部分变位图,打印时基本看不出来。

第五个,图要有"读图引导"。一张平行坐标扔给别人,绝大多数人第一反应是"这啥"。我现在的习惯是在图旁边加两三行注解,比如"注意看灰色高亮那条:活跃天数很高但退货率也高,属于高风险高活跃的异常群体",直接把结论指出来。图本身不会说话,得有人替它说。

最后一个体会:平行坐标这工具,画出来只要一行代码,画好要一小时,画到能直接进汇报材料要半天。时间几乎全花在数据准备和维度排序上,而不是绘图本身。我现在接到这类需求,第一件事不是打开画图,而是先跑一遍缺失、异常、偏度、判别力的体检,把候选维度池收拾干净,再动手画。前面这些活干扎实了,最后出图基本一次成型;反过来,数据没收拾干净就急着画,改十遍也是糊的。这套流程我用了两三年,从最初的"画完自己都看不懂",到现在基本能做到一张图讲清一个结论,中间省下来的返工时间,远比多写几行预处理代码划算。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询