做用户运营最头疼的事情之一,就是面对几十万用户却不知道他们之间有什么差异。比如同样是一天访问三次,有人每次停留几秒就走,有人能看十几分钟商品详情;有人从不下单,有人每月固定复购。如果只看平均值,这些差异会被完全抹平。聚类分析恰好是解决这个问题的利器——它不需要提前给用户打标签,而是让算法根据行为特征自动把相似的用户划到一起。本文用PHP来实现这套流程,从数据准备、特征提取到K-Means聚类,完整走一遍。

一、聚类之前:先把用户行为变成可计算的向量
聚类算法不能直接吃原始日志,它只认数字。所以第一步是把每个用户的行为汇总成一组的特征值,这一步做的好坏直接决定聚类效果。常见的用户行为特征包括:近30天的访问次数、平均单次停留时长、下单次数、浏览品类数量、活动参与次数等等。
假设我们有一张用户行为日志表,结构大致如下:
CREATE TABLE user_behavior_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
action VARCHAR(50) NOT NULL, -- pv, order, cart, share 等
duration INT DEFAULT 0, -- 本次行为停留秒数
created_at DATETIME NOT NULL,
INDEX idx_user (user_id, created_at)
);
接着写一个SQL把这些离散的日志聚合成每个用户一行特征:
SELECT
user_id,
COUNT(*) AS total_actions,
SUM(CASE WHEN action = 'order' THEN 1 ELSE 0 END) AS order_count,
SUM(CASE WHEN action = 'cart' THEN 1 ELSE 0 END) AS cart_count,
ROUND(AVG(duration), 1) AS avg_duration,
COUNT(DISTINCT CASE WHEN action = 'pv' THEN created_at DIV 10000 ELSE NULL END) AS active_slots
FROM user_behavior_log
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id;
拿到特征之后有个非常关键的步骤——标准化。访问次数可能是几百,而下单次数可能只有个位数,量纲差距太大时,距离计算会被大数值特征主导,聚类结果会严重失真。最常用的是Z-Score标准化,把每一列转成均值为0、标准差为1的分布:
<?php
function standardize(array $matrix): array
{
$colCount = count($matrix[0]);
$rowCount = count($matrix);
$result = [];
for ($c = 0; $c < $colCount; $c++) {
// 计算每一列的均值
$sum = 0;
for ($r = 0; $r < $rowCount; $r++) {
$sum += $matrix[$r][$c];
}
$mean = $sum / $rowCount;
// 计算标准差
$variance = 0;
for ($r = 0; $r < $rowCount; $r++) {
$variance += pow($matrix[$r][$c] - $mean, 2);
}
$std = sqrt($variance / $rowCount) ?: 1;
for ($r = 0; $r < $rowCount; $r++) {
$result[$r][$c] = ($matrix[$r][$c] - $mean) / $std;
}
}
return $result;
}
这里有个细节值得注意:如果某列标准差为0(比如所有用户都没有分享行为),直接除零会报错,所以代码里用了?: 1兜底。另外对于严重偏斜的指标,比如下单次数,建议先做对数变换log(1+x)再标准化,否则头部少量剁手党会把整个分布拉歪。
二、用纯PHP手写K-Means聚类
K-Means是无监督学习里最经典也最容易落地的聚类算法,原理并不复杂:先随机挑K个点当簇中心,然后把每个样本分配给最近的中心,再根据分配结果重新计算各簇的中心点,反复迭代直到中心不再明显移动。下面是完整的PHP实现:
<?php
class KMeans
{
private int $k;
private int $maxIterations;
public function __construct(int $k = 4, int $maxIterations = 100)
{
$this->k = $k;
$this->maxIterations = $maxIterations;
}
// 计算两个向量之间的欧氏距离
private function distance(array $a, array $b): float
{
$sum = 0;
for ($i = 0; $i < count($a); $i++) {
$sum += pow($a[$i] - $b[$i], 2);
}
return sqrt($sum);
}
public function cluster(array $data): array
{
$n = count($data);
// 随机选取K个样本作为初始中心
shuffle($data);
$centroids = array_slice($data, 0, $this->k);
$assignments = array_fill(0, $n, -1);
for ($iter = 0; $iter < $this->maxIterations; $iter++) {
$changed = false;
// 第一步:把每个点分配给最近的中心
foreach ($data as $i => $point) {
$bestCluster = 0;
$bestDist = PHP_FLOAT_MAX;
foreach ($centroids as $c => $centroid) {
$d = $this->distance($point, $centroid);
if ($d < $bestDist) {
$bestDist = $d;
$bestCluster = $c;
}
}
if ($assignments[$i] !== $bestCluster) {
$assignments[$i] = $bestCluster;
$changed = true;
}
}
if (!$changed) break; // 已经收敛,提前退出
// 第二步:重新计算每个簇的中心
for ($c = 0; $c < $this->k; $c++) {
$sums = array_fill(0, count($data[0]), 0);
$count = 0;
foreach ($data as $i => $point) {
if ($assignments[$i] === $c) {
for ($j = 0; $j < count($point); $j++) {
$sums[$j] += $point[$j];
}
$count++;
}
}
if ($count > 0) {
for ($j = 0; $j < count($sums); $j++) {
$centroids[$c][$j] = $sums[$j] / $count;
}
}
}
}
return $assignments;
}
}
使用方式很简单,传入标准化后的特征矩阵即可:
<?php
// $rows 是从数据库取出的特征数组
$data = standardize($rows);
$kmeans = new KMeans(4);
$labels = $kmeans->cluster($data);
// 把聚类结果写回用户表
foreach ($rows as $i => $row) {
$stmt = $pdo->prepare("UPDATE users SET cluster_id = ? WHERE id = ?");
$stmt->execute([$labels[$i], $row['user_id']]);
}
需要提醒的是,K-Means对初始中心敏感,随机初始化可能陷入局部最优。改进办法是多次运行取效果最好的一次,或者采用K-Means++初始化策略——让初始中心尽量彼此远离。对于几万条数据、五六个特征的场景,纯PHP跑一次也就几秒钟,完全够用;但如果用户量到了百万级,建议把计算部分改成离线脚本定时跑,或者直接交给数据库端的聚合来分担压力。
三、K值怎么选?用轮廓系数自动评估
聚成几类合适是新手最常踩的坑。K太小则用户被粗暴合并,K太大则每类人太少没法运营。硬拍脑袋不如用指标说话,轮廓系数(Silhouette Coefficient)是评估聚类质量最直观的指标,取值范围在-1到1之间,越接近1说明该点离自己的簇越近、离别的簇越远,整体平均轮廓系数最高的K通常就是较优选择。
计算逻辑为:对每个样本,算它同簇其他点的平均距离a(凝聚度),再算它到最近的其他簇各点的平均距离b(分离度),轮廓值就是(b - a) / max(a, b)。完整实现:
<?php
function silhouetteScore(array $data, array $labels): float
{
$n = count($data);
$distances = [];
// 预计算两两距离,样本多时可改用分块计算
for ($i = 0; $i < $n; $i++) {
for ($j = $i + 1; $j < $n; $j++) {
$d = 0;
for ($t = 0; $t < count($data[$i]); $t++) {
$d += pow($data[$i][$t] - $data[$j][$t], 2);
}
$distances[$i][$j] = $distances[$j][$i] = sqrt($d);
}
}
$total = 0;
for ($i = 0; $i < $n; $i++) {
$inCluster = []; // 同簇内距离
$outClusters = []; // 各外簇距离
for ($j = 0; $j < $n; $j++) {
if ($i === $j) continue;
if ($labels[$j] === $labels[$i]) {
$inCluster[] = $distances[$i][$j];
} else {
$outClusters[$labels[$j]][] = $distances[$i][$j];
}
}
$a = $inCluster ? array_sum($inCluster) / count($inCluster) : 0;
$b = PHP_FLOAT_MAX;
foreach ($outClusters as $ds) {
$b = min($b, array_sum($ds) / count($ds));
}
if ($b === PHP_FLOAT_MAX) continue;
$total += ($b - $a) / max($a, $b);
}
return $total / $n;
}
// 遍历不同K值,找出轮廓系数最高的那个
for ($k = 2; $k <= 8; $k++) {
$kmeans = new KMeans($k);
$labels = $kmeans->cluster($data);
printf("K=%d, 轮廓系数=%.3f\n", $k, silhouetteScore($data, $labels));
}
注意两两距离的计算复杂度是O(n²),用户量大时全量计算不现实,可以随机抽样一两千个点来估算轮廓系数,效果足够指导K的选择。另外肘部法则也是常见手段:画出不同K对应的簇内误差平方和曲线,找拐点位置。实际操作中建议两种方法结合,再叠加业务可解释性做最终决定——毕竟聚出来的每个簇都需要能被运营同学理解和运用。
四、不想手写?直接用php-ml库搞定
如果不想维护自己的算法实现,PHP生态里有个现成的机器学习库php-ml,通过Composer安装即可:
composer require php-ai/php-ml
用php-ml做K-Means聚类只需要十几行代码:
<?php
require 'vendor/autoload.php';
use Phpml\Clustering\KMeans;
use Phpml\Preprocessing\Normalizer;
// $samples 是特征数组,每个元素为一行数值
$normalizer = new Normalizer(Normalizer::NORM_STD);
$normalizer->transform($samples);
$kmeans = new KMeans(4);
$clusters = $kmeans->cluster($samples);
foreach ($clusters as $clusterId => $points) {
echo "簇 {$clusterId} 包含 " . count($points) . " 个用户\n";
}
php-ml内置了标准化器,也自带K-Means++初始化,省去了不少细节工作。除此之外它还提供DBSCAN等基于密度的聚类算法,DBSCAN的优势是不需要预先指定K,而且能自动识别离群点——那些行为极度异常的用户(比如爬虫账号)会被标记为噪声而不强行归入任何簇,这在真实数据里非常有价值。缺点是它对参数(邻域半径和最小点数)比较敏感,需要结合标准化后的数据分布反复调试。
五、聚类结果怎么落地到业务
算法跑完只是开始,真正的价值在于结果解读和运营应用。拿到每个簇之后,应该统计各簇的特征均值,转成运营能看懂的语言,比如:簇1平均月访问20次、下单4次、停留8分钟,这就是核心活跃买家;簇2访问频繁但零下单,是典型的只逛不买型,适合发优惠券刺激首单;簇3各项指标都垫底且呈下降趋势,是流失预警对象,需要召回策略介入。
<?php
// 输出每个簇的特征画像
$sql = "SELECT cluster_id,
COUNT(*) AS user_cnt,
ROUND(AVG(total_actions),1) AS avg_actions,
ROUND(AVG(order_count),2) AS avg_orders,
ROUND(AVG(avg_duration),1) AS avg_stay
FROM users JOIN user_features USING(user_id)
GROUP BY cluster_id";
foreach ($pdo->query($sql) as $row) {
printf("簇%s: %d人, 日均行为%s次, 下单%s单, 停留%s秒\n",
$row['cluster_id'], $row['user_cnt'],
$row['avg_actions'], $row['avg_orders'], $row['avg_stay']);
}
落地时还有三点经验值得分享。第一,聚类要定期重跑,用户行为会随季节、活动、产品改版漂移,上个月的分群这个月可能就不准了,建议按周或按月更新。第二,聚类结果要和业务指标做交叉验证,比如统计各簇的GMV贡献和次月留存,确认分群确实区分出了价值差异,而不是算法层面的自说自话。第三,特征工程比算法选择更重要,与其纠结用K-Means还是DBSCAN,不如多花时间设计更贴近业务的行为指标,比如加入时间衰减因子,让近期的行为权重更高,往往能带来立竿见影的聚类质量提升。
整套方案的核心链路可以总结为:日志聚合、特征标准化、确定K值、聚类计算、结果回写、业务解读。PHP虽然不是机器学习的主流语言,但在数据规模可控、团队技术栈以PHP为主的前提下,这套方案的开发和维护成本远低于引入Python服务,对于绝大多数中小型网站的用户分群需求完全够用。