先说结论,免得看成人品问题:算法没算错,是我给它喂了一张结构上对称的图。而更值得记的是后面每一步——怎么绕开、怎么改图、以及分数没区分度时这个指标该不该留。

01症状是一个常数

卖家排名跑出来,每一行的 PageRank 值都一模一样。不是“差别很小”,是同一个数。

这类结果最容易骗人的地方在于它看起来像 bug:值精确相等,像是在某处被写死了。我先去查了写出链路(顶点 id 映射、按 node_type 过滤取分、落表列序),全对。然后才回头怀疑图本身。

02为什么会塌缩

文档里我当时给的解释是:卖家之间不直接相连,全部通过商品连通;这样的二部图收敛后同类节点分数相同。

这句是对的,但我要把“哪部分是机制、哪部分是我的推断”分开写清楚,不然读者会当成定理:

  • 结构对称是真的。同一类节点(卖家层)之间没有任何边,它们的可达性完全由共享的中介节点(商品)决定。
  • 图上存在一个近乎覆盖全图的巨大连通分量,所以卖家层不是被切成几块各自有解,而是处在同一个连通块里。
  • 在这两个条件下,PageRank 的解对“交换任意两个卖家”这个操作是不变的——对称的输入不允许产生不对称的解。分数趋同是方程的性质,不是数值精度问题。
  • 我额外怀疑热门商品形成的星形结构会把分数吸到商品层、稀释卖家层差异。这一条我没有做过对照实验,只是解释方向,不该当结论。

判据我后来简化成一句话:图算法的分数没有区分度时,先证明这张图有对称性,再怀疑代码。

03第一次绕行:社区发现跑不动,退回“主品类聚类”

同一张图上另一件事是社区发现。GraphFrames 不在 Spark 默认依赖里,得提交时带 --packages,这个好解决;connectedComponents 在这张图上直接失败就不好解释了——我在问题记录里写的根因是“图规模过大或内存不足”,那个“或”字就是当时的真实状态:没定论。

迭代式连通分量每轮都要跨分区 shuffle,一张十万级顶点、边权重还要聚合的图,落在可用内存本就不宽裕的单机后端上,失败方式很杂。我没有继续调,而是把“卖家社区”这个概念换了一个更便宜的实现:

sql
-- 取每个卖家销售额最高的品类作为 community_id
ROW_NUMBER() OVER (PARTITION BY seller_id ORDER BY sales_count DESC) = 1
-- 社区规模就是同品类的卖家数
COUNT(*) OVER (PARTITION BY main_category)

这不是社区发现,是按主品类聚类。它能回答问题里“哪些卖家在做同一类货”的那一半,回答不了“交易结构上谁和谁抱团”。

这里留了一个我不太想辩解的问题:设计文档和执行计划里这个环节写的都是 Louvain。实现换了,文档没跟着改——所以任何人拿设计文档去核对代码,会得到“实现不完整”的结论。代码是对的、文档是旧的这种分叉,比纯粹的 bug 更难被发现,因为两边单独看都合理。

04第二次尝试:换图结构,而不是换参数

Scala 链路重做时我把图换成四部异构图:customer → product → seller,product → category,边的权重取去重订单数和 GMV。想法很直接——卖家层不再是“只通过商品两跳相连”的孤立一层。

算法侧用的是 GraphX 自带的 pageRank(numIterations):

scala
graph.pageRank(numIterations).vertices

选固定迭代次数版本而不是带收敛阈值的版本是有意的:论文实验要可复现,tol 版本的停止点会随分区数和数据分布漂。代价是收敛与否不再由算法自己保证,所以我得把“迭代次数”当参数记录下来——而这件事我做得不合格:两份文档里记的迭代次数不一致,脚本默认值和实测记录也不是同一个数。现在看,一个不进版本控制的实验参数就等于没有参数。

构图时还有个小设计值得留:顶点集是从边的两端派生的(flatMap 出端点再赋默认属性),这样“只有孤立节点没有边”的实体不会凭空出现,但代价是节点属性得另查表补齐。

05顶点 ID 这件小事反而做对了

字符串业务 id 要映射成 GraphX 的 Long 顶点号,我的方案是:

scala
val hash = md.synchronized { md.reset(); md.digest(s"$prefix:$id".getBytes("UTF-8")) }
ByteBuffer.wrap(hash, 0, 8).getLong & Long.MaxValue

三个决定各有原因:带 prefix 让不同实体类型的相同 id 不撞;取前 8 字节再清符号位保证非负(负顶点号在 GraphX 里会以奇怪的方式出问题);require 对空值和空串直接抛异常——宁可炸,不要静默生成一个假 id 进图,否则图上会多出一个谁也解释不了的节点。

一个不起眼的坑:MessageDigest 不是线程安全的,而这个函数被包成 UDF 在 executor 上并发调用。所以 md.synchronized { md.reset(); ... } 那两句是必须的——共享实例省掉了每次 new 的开销,代价就是要自己管串行化。

碰撞我没有做检测,靠的是位空间足够大。这是有意的取舍,但它没有被测试覆盖,属于“我知道它没证明”的部分。

06相关性为零,不等于指标没有意义

PageRank 分数和卖家 GMV 的 Pearson 相关算出来接近 0。我当时的第一反应是这指标没用、该删。交付标准里我给自己立的规矩反而是:不能因为相关性为零就删掉 PageRank,必须说清它量的是“结构影响力”而不是“交易规模”,是另一个维度。

这个说法我现在仍然认同,但它有前提:分数得有区分度。相关性为零和“没有区分度”是两件不同的事,我把它们混在一起给自己找台阶找得太快了。

07服务层最后是怎么处理的

前端卖家榜我没有按 PageRank 排。代码注释里写得很明白:

python
# GraphX 作业当前输出的 seller_pagerank 为常量(distinct=1),
# 按 PageRank 排序无意义,因此 Top 榜改按 GMV 排序;
# PageRank 列保留以便在图作业修复后对比。

这是我在这个项目里比较喜欢的一个处置:不假装指标有效,也不把它删掉,而是让排序依据和指标来源在字段上一致,并留一列供修复后对比。血缘视图里那一行也标着“PageRank 待修”。

08还没修好的

  • 换图之后集群那次跑出来的卖家分数仍然是同一个常数(服务层那句 distinct=1 就是当时的实际状态)。四部异构图为什么没有恢复区分度,我没有查到底——怀疑方向是 category 层的星形节点和边权聚合方式,但没做实验。
  • 迭代次数这个参数在文档之间不一致,需要一次“把实际跑批参数回写进文档”的动作。
  • 顶点 id 碰撞只做概率论证,没有加“同 id 不同前缀”或“业务 id 反查唯一性”的校验作业。
  • “结构影响力”这个叙述如果要进论文,需要一张对照表:PageRank 高但 GMV 低的卖家到底是谁、为什么——这张表我还没做。

09相关文章