竞争分析与对策的数据工程实践:从指标建模到信号验证

竞争分析与对策的数据工程实践:从指标建模到信号验证 简介这是长江商学院陶志刚教授的市场竞争与对策课件讲义聚焦博弈论在管理经济学与战略决策中的应用适合MBA、商科学生及企业管理者理解竞争动态、均衡与定价策略。压缩包为1个pptx文件共676KB以图文和案例页形式呈现便于直接阅读与课堂演示。内容从捕虾游戏引入市场供需与产量决策逐步讲解纳什均衡的优化性和稳定性并以百事可乐与可口可乐的“竞争两难”说明价格战成因与共谋、差异化破局思路还结合新旧世界葡萄酒商争夺900亿美元全球市场的案例对比传统艺术化与科学创新两种生产营销体系。已有85人浏览学习。这套讲义既给出博弈论基础框架又提供可迁移到现实竞争环境中的思考方式适合作为战略课程补充材料或自学参考。1. 把“讲义三市场竞争和对策”的事当作数据工程问题来做一份“市场竞争和对策”的讲义放到技术团队面前通常不会被当成 PPT 看。大家更关心的是这份材料里讲的竞争格局、份额变化、价格手段能不能在我的数据环境里重新算出来。实际干过竞品分析的人都有同感真正难的不是理解“竞争激烈”这个结论而是把“激烈”拆成可观测、可预警、可回测的指标。这篇文章不假设你已经拿到了长江商学院陶志刚老师那版讲义只假设你手里有一个需要持续监控的市场以及一台能跑 Python 和 SQL 的工作机。目标是把市场竞争和对策这件事拆成一套完整的数据链路先做指标建模再做采集入库接着计算份额、弹性、竞争强度最后验证信号有没有被误报带偏。适合的读者包括做增长和策略的数据分析师、负责自家产品定价的产品经理、以及想给业务方搭一套竞争情报系统的后端工程师。读完你能直接搭出一个最小可用的竞争情报管道而不是只记住几个概念。2. 从“市场竞争”到数据模型先定指标再谈采集2.1 把“五力”翻译成可观测字段市场上主流的竞争分析框架不管叫五力模型还是 SCP 范式落到数据工程里都逃不开一个事实理论只给维度不给字段。你得自己决定用哪些数据代表“供应商议价能力”用哪些数据代表“替代品威胁”。我一般会把五力模型映射成四张可计算的表价格快照表、SKU 覆盖表、流量渠道表、渠道事件表。价格快照表记录每个竞品在不同时点的售价和促销折扣SKU 覆盖表记录在售商品数量和缺货数流量渠道表记录搜索热度、榜单排名、应用商店评论量渠道事件表记录新品发布、价格调整、补贴活动这类离散事件。这种映射的好处是每个理论概念都有至少一个代理变量。比如“替代品威胁”可以用相近品类的新品上架速度和搜索热度来代理“新进入者威胁”可以用一段时间内新增竞品的数量来代理。代理变量不一定完全准确但它让分析从“感觉竞争变大了”变成了“上月替代品流量周增速超过 20%”。2.2 设计竞争快照底表时间、范围、字段都不可含糊竞争分析最忌讳的是只存当前状态。你如果不在每个采集周期里把快照存下来三个月后就无法回答“份额是怎么迁移到这个位置的”这个问题。所以表结构里一定有一个时间字段而且必须是带时区的 TIMESTAMPTZ。CREATE TABLE IF NOT EXISTS competitor_snapshot ( competitor_id VARCHAR, category VARCHAR, captured_at TIMESTAMPTZ, sku_count INTEGER, avg_price DECIMAL(12,2), promo_discount DECIMAL(5,4), stockout_sku INTEGER, traffic_rank INTEGER, PRIMARY KEY (competitor_id, category, captured_at) );这段建表语句的核心是把主键设成“竞品 ID 品类 采集时间”三个字段的组合。这意味着同一次采集中一个竞品在一个品类下只保留一条记录。下次再采数据就写入一条新的时间戳记录而不是覆盖旧数据。promo_discount用 DECIMAL(5,4) 存 0.15 这样的折扣率避免用浮点类型产生精度问题。traffic_rank是排名快照记录当天该竞品在目标类目下的搜索或销量排名这个字段对后面计算份额迁移很有用。如果还要接收事件型数据比如某竞品突然降价、某新品上线可以单独建一张事件表。事件表不要和快照表混在一起因为快照是周期性写入的事件是异步到达的混在一起会让聚合查询变得非常别扭。2.3 用“原始层 标签层”避免脏数据污染决策很多人会把抓到的数据直接整理成最终要用的格式然后写进一张表。这个习惯在竞争分析场景里很危险。昨天解析逻辑写错一个字段今天整列数据就错了而且因为旧数据已经被覆盖根本没法回查。我的做法是强制分两层。第一层叫 raw 层原样保留采集回来的 HTML、JSON 或 CSV 内容哪怕是带格式的完整文本也没关系。第二层叫 label 层从 raw 层解析出结构化字段。任何解析规则变了只影响 label 层raw 层永远不动。这个设计和数据湖的思路是一致的只是竞争分析不需要那么重的基础设施。一个 DuckDB 文件或者一张 PostgreSQL 表按日期分区保存 raw 文本就够了。等解析逻辑稳定下来了再回放 raw 层的数据重新生成 label 层。关键是这个回放过程必须能随时执行所以采集端要留着原始报文体而不是只存清洗后的价格数字。3. 采集端落地竞品价格、SKU 与渠道动态的抓取和入库3.1 一个稳健的采集器先过三关频率、字段、异常采集竞品数据最常见的坑不是反爬而是采集器本身不稳定。跑了两周才发现某两天因为页面结构改版所有价格字段解析出来都是空值导致那两天的分析结果直接失真。所以采集器上线前要过三关。第一关是频率按页面更新速度决定采集间隔价格页面可以每小时采促销活动页可以每半小时采而静态类目排名每天采一次就够了。第二关是字段校验每次解析完后检查非空率和数字格式如果某字段空值率超过 5%直接丢弃该批次并报警。第三关是异常隔离解析失败的原始内容放进隔离区绝不进仓库。另外在动手写爬虫之前先看一眼目标站点的采集规范文件。curl 一下看看站点允许抓取哪些路径和访问频率是比较稳妥的做法。curl -s https://example.com/robots.txt | head -n 30这段命令返回的是站点声明允许或禁止的路径列表。重点看 Disallow 规则和 Crawl-delay 字段Crawl-delay 会告诉你每次请求之间至少要隔多少秒。需要注意采集规范文件的允许范围不等于你可以无视站点服务条款正式使用前还需要按目标站点的规则确认自己的用途和身份标识。3.2 用 requests BeautifulSoup 抓价格列表的可执行示例拿到页面后第一版采集器可以用最简单的组合实现requests 负责请求BeautifulSoup 负责解析。下面是一段可独立运行的示例提取列表页上的所有价格文本。import requests from bs4 import BeautifulSoup from time import sleep from random import uniform def fetch_price_list(url: str, selector: str, interval(1.5, 3.0)) - list[float]: session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (compatible; MarketIntelBot/1.0; https://example.com/bot) }) resp session.get(url, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) prices [] for node in soup.select(selector): raw node.get_text(stripTrue).replace(¥, ).replace(,, ) try: prices.append(float(raw)) except ValueError: continue sleep(uniform(*interval)) return pricesselector是 CSS 选择器指向包含价格文本的 DOM 元素例如li.product-price。interval参数用随机区间控制请求间隔避免每个请求都精确卡在同一个时间点。resp.raise_for_status()会在 HTTP 状态码为 4xx 或 5xx 时直接抛异常避免把错误页面当成正常内容解析。解析部分做了两件防御工作第一先删掉货币符号和千分位逗号第二float 转换失败就跳过不中断整批采集。这种逐条容错在面对页面局部改版时非常重要一个坏数据点最多影响一条记录不会让整轮采集失败。现在的电商页面大量使用 JavaScript 渲染爬出来的 HTML 里可能没有价格节点。这种情况下最快的替代方案是找页面底部的 API 接口用浏览器开发者工具里的网络面板查看 XHR 请求直接请求 JSON 数据源。解析逻辑不用改只是把 HTML 选择器换成 JSON 的 key 路径。3.3 把采集结果写进 DuckDB去重、增量与留存采集到的数据进入分析流程之前需要一个能快速查询的本地存储。DuckDB 是合适的选择它没有独立服务进程一个文件就是一个数据库非常适合单机分析。入库时要处理的核心问题是避免同一时间点的重复记录。import duckdb from datetime import datetime, timezone conn duckdb.connect(competition.duckdb) conn.execute( CREATE TABLE IF NOT EXISTS price_snapshot ( product_id VARCHAR, competitor VARCHAR, price DECIMAL(12,2), captured_at TIMESTAMPTZ ); ) conn.execute( INSERT OR REPLACE INTO price_snapshot VALUES (?, ?, ?, ?) , (P-10086, competitor_a, 129.00, datetime.now(timezone.utc)))INSERT OR REPLACE的行为依赖表上的主键或唯一约束如果同样的product_id competitor captured_at已经存在就会用新值替换旧值。这比先查再插的写法少一次请求而且在并发写入时更安全。captured_at统一用 UTC 时间到了分析阶段再按业务时区转换避免不同机器上的本地时区造成时间错位。留存策略上raw 层数据按月归档label 层快照永久保留。价格快照这种数据占用的空间不大一万个 SKU 每小时采一次一年也才几千万行完全在普通商业笔记本可以处理的范围内没必要过早做聚合压缩。4. 竞争态势的计算层份额迁移、排名与价格弹性4.1 SQL 计算周维度份额迁移与排名有了快照表之后第一个要算的通常是份额趋势。份额的统计口径不是唯一标准可以用 SKU 数量算覆盖份额也可以用销量算销售额份额。在只有价格和覆盖数据的情况下SKU 覆盖份额是最容易得到的近似指标。WITH weekly AS ( SELECT competitor, date_trunc(week, captured_at) AS week_start, count(DISTINCT product_id) AS live_skus, median(price) AS median_price FROM price_snapshot WHERE captured_at current_date - INTERVAL 90 days GROUP BY 1, 2 ) SELECT competitor, week_start, live_skus, median_price, round( 100.0 * live_skus / sum(live_skus) OVER (PARTITION BY week_start), 2 ) AS sku_share, rank() OVER (PARTITION BY week_start ORDER BY live_skus DESC) AS rank_no FROM weekly ORDER BY week_start DESC, rank_no;这段 SQL 的要点在窗口函数上。sum(live_skus) OVER (PARTITION BY week_start)计算的是同一周内所有竞品的 SKU 总数然后用每个竞品的数量除以这个总数就得到当周的覆盖份额。rank()按每周内 SKU 数量降序排名rank_no 1表示当周覆盖最全的竞品。如果发现某周第一名和第二名的份额差距在缩小但价格中位数差没有同步缩小说明竞争正在从价格维度转向覆盖维度。此时再看上一周的营销事件表往往能找到新品发布或渠道拓展的记录。4.2 用对数回归估算价格弹性价格弹性是竞争分析里被提到最多、但算得最少的一个指标。原因很简单大部分团队没有干净的历史价格和销量数据。如果你已经积累了一段时间的快照那可以做一个简化的对数线性回归。import pandas as pd import numpy as np def price_elasticity(df: pd.DataFrame) - float: sub df[[price, sales_qty]].replace(0, np.nan).dropna() x np.log(sub[price]) y np.log(sub[sales_qty]) beta np.polyfit(x, y, 1)[0] return round(beta, 3)np.polyfit(x, y, 1)在双对数坐标下拟合一条直线返回的第一个系数就是斜率也就是价格弹性的近似值。典型结果是负值比如 -1.8表示价格每上涨 1%销量大约下降 1.8%。这里的sales_qty可以是自己的销量也可以是竞品页面上的销量标签。如果你拿自己和某个竞品的数据交替做这个回归可以对比不同品牌面对价格变动时的敏感程度。弹性绝对值大的品牌价格战对它伤害更大弹性绝对值小的可以承受更长时间的折扣战。要注意这个分析只能看到相关性不能直接当成因果关系价格之外的因素需要结合事件表辅助判断。4.3 竞争强度计分卡与对策触发条件份额和弹性都是单维度指标真正推动团队行动的是综合评分。我会根据业务特点做一个竞争强度计分卡把所有可量化信号加权成一个分数。信号门槛分值竞品平均折扣率高于 20%5我方 SKU 覆盖率落后低于领先者 30 个百分点3替代品流量周增速超过 20%2新进入者上线过去 30 天出现新品牌4价格弹性绝对值大于 1.52竞品缺货率超过 10%-3这个计分卡的逻辑不是要预测什么而是让“对策”有明确的触发条件。总分大于等于 10 时进入重点关注名单产研和运营团队每周过一遍大于等于 15 时触发专项应对比如跟随调价或加快新品排期。分数小于 3 时维持常规监控不消耗团队注意力。计分卡的阈值必须定期用历史数据回验。如果过去一个月里触发重点关注的周次里有一半以上最后并没有发生实质性竞争恶化就该把阈值往上调减少空转。5. 对策信号验证与误报收敛把分析结果放进回放闭环5.1 离线回放让解析逻辑对旧数据负责竞争分析系统跑起来之后最怕的不是没信号而是信号错了没人知道。所以我坚持每个解析脚本都要支持离线回放。回放的意思是用当前的解析逻辑重新处理过去 7 天的 raw 数据然后把新解析结果和当天入库时的结果做对比。对比的维度和注意点可以参考下面的 Python 片段它统计每一轮解析后的空值数量和价格差import pandas as pd def diff_parsed(current: pd.DataFrame, replayed: pd.DataFrame) - pd.DataFrame: diff current.merge( replayed, on[product_id, captured_at], suffixes(_old, _new) ) invalid diff[ diff[price_new].isna() | (abs(diff[price_new] - diff[price_old]) 0.01) ] return invalid这段代码通过merge把两轮解析结果对齐到同一对主键上再找出价格缺失或差异大于 0.01 的记录。如果某一天的差异行数明显偏多就说明当时页面结构变化了而旧解析逻辑没有兼容。只有把这样 的差异控制到接近零计分卡产生的信号才值得业务方相信。5.2 用 MAD 替换 z-score减少长尾误报价格异常检测是竞争情报里最常用的警报但用传统的均值加减三倍标准差很容易被极端值带偏。比如某个 SKU 突然被下架价格变成 0均值会被拉低导致正常价格反而被标记为异常。中位数绝对偏差在这个场景下更稳定。from scipy.stats import median_abs_deviation def mad_based_alert(series, k4.5): med series.median() mad median_abs_deviation(series) lower med - k * 1.4826 * mad upper med k * 1.4826 * mad return series[(series lower) | (series upper)]median_abs_deviation计算每个样本到中位数的绝对偏差的中位数1.4826是把 MAD 调整到与正态分布标准差同一尺度的常数。k4.5是比较保守的阈值意味着只有偏离中位数 4.5 个 MAD 的值才报警。z-score 对均值很敏感而 MAD 看的是中位数所以即便有少量极端值正常数据的基线也不会被拉走误报率会低很多。如果切换到 MAD 后警报数量大幅下降说明原来的异常检测被极端值劫持了。5.3 把对策信号固化到日调度最后的技巧是把验证好的采集、解析、计分、报警串成一个可重复执行的日调度。每天早上固定时间运行结束后在日志里输出统计摘要我主要看processed、alerts、quarantine三个计数器分别代表本轮处理的产品数、触发的警报数和被隔离的可疑记录数。当quarantine回归到接近 0业务方再看到来自这套管道的数据就不会再把“竞品降价了”当成一句无法核实的话。本文还有配套的精品资源点击获取