简介:这是长江商学院陶志刚教授的市场竞争与对策课件讲义,聚焦博弈论在管理经济学与战略决策中的应用,适合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, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") prices = [] for node in soup.select(selector): raw = node.get_text(strip=True).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.5 | +2 |
| 竞品缺货率 | 超过 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, k=4.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 调整到与正态分布标准差同一尺度的常数。k=4.5是比较保守的阈值,意味着只有偏离中位数 4.5 个 MAD 的值才报警。
z-score 对均值很敏感,而 MAD 看的是中位数,所以即便有少量极端值,正常数据的基线也不会被拉走,误报率会低很多。如果切换到 MAD 后警报数量大幅下降,说明原来的异常检测被极端值劫持了。
5.3 把对策信号固化到日调度
最后的技巧,是把验证好的采集、解析、计分、报警串成一个可重复执行的日调度。每天早上固定时间运行,结束后在日志里输出统计摘要,我主要看processed、alerts、quarantine三个计数器,分别代表本轮处理的产品数、触发的警报数和被隔离的可疑记录数。当quarantine回归到接近 0,业务方再看到来自这套管道的数据,就不会再把“竞品降价了”当成一句无法核实的话。
本文还有配套的精品资源,点击获取