Think Bayes Note 9: 价格竞猜
1. 价格竞猜
在一个名为“正确的价格”的价格竞猜的电视节目中,会为参赛的两名选手各准备一组商品,两名选手要尝试猜测自己商品的价格。如果选手出价高于实际价格,则会直接输掉;如果选手出价低于实际价格,则出价误差较小的选手获胜;如果误差低于 250 美元,则该选手还会赢得对手的奖品。
举例来说,纳撒尼尔和莱希娅两人参加了节目,纳撒尼尔要竞猜的商品包括洗碗机、酒柜、笔记本电脑和一辆汽车,他的出价为 26000 美元;莱希娅要的商品包括弹球机、电视游戏、台球桌和一次去巴拿马的旅行,她的出价为 21500 美元。纳撒尼尔的商品的实际价格为 25347 美元,他的出价比实际价格高,直接输掉了比赛;莱希娅的商品的实际价格为 21578 美元,她赢得了比赛,而且她的出价与实际价格间的误差少于 250 美元,她还赢得了纳撒尼尔的商品。
根据这一场景,可以提出如下的问题:
- 在看到商品前,参赛者应当如何判断商品价格的先验分布;
- 看到商品后,参赛者应当如何修正自己的预期;
- 基于后验分布,参赛者应当如何出价。
2. 先验概率
史蒂夫·吉收集了 2011 年到 2012 年期间这一节目中商品的价格和选手的出价(2011、2012),读取方法如下:
import pandas as pd
def read_showcases(file_name):
df_data = pd.read_csv(file_name)
df_data.dropna(inplace=True)
df_data.set_index(df_data.columns[0], inplace=True)
df_data = df_data.astype(float)
df_data = df_data.T
df_data.columns.name = None
return df_data
df_data = read_showcases("./data/showcases.2011.csv")
prices_1 = df_data.loc[:, "Showcase 1"]
bids_1 = df_data.loc[:, "Bid 1"]
diffs_1 = df_data.loc[:, "Difference 1"]
prices_2 = df_data.loc[:, "Showcase 2"]
bids_2 = df_data.loc[:, "Bid 2"]
diffs_2 = df_data.loc[:, "Difference 2"]
其中 prices_1 和 prices_2 为两个选手的商品的实际价格,bids_1 和 bids_2 为两个选手的出价,diffs_1 和 diffs_2 为两个选手的出价和真实值之间的误差。
定义表示概率密度的类 Pdf 和使用高斯核密度估计的类 EstimatedPdf 如下:
class Pdf(object):
def density(self, x):
raise UnimplementedMethodException()
def make_pmf(self, xs, name=None):
pmf = Pmf(name=name)
for x in xs:
pmf.set(x, self.density(x))
pmf.normalize()
return pmf
class EstimatedPdf(Pdf):
def __init__(self, sample):
self.kde = scipy.stats.gaussian_kde(sample)
def density(self, x):
return self.kde.evaluate(x)
然后使用 EstimatedPdf 估计两个选手商品的真实价格的 PDF,生成 PMF 并绘图:
low, high, n = 0, 75000, 101 xs = np.linspace(low, high, n) pdf_price_1 = EstimatedPdf(prices_1) pmf_price_1 = pdf_price_1.make_pmf(xs, 'price 1') pdf_price_2 = EstimatedPdf(prices_2) pmf_price_2 = pdf_price_2.make_pmf(xs, 'price 2') pmf_price_1.plot_with([pmf_price_2])
图像为:
可见最常见的商品的价格在 28000 附近。
3. 选手建模
选手出价的误差(diff)为商品真实价格(price)减去选手出价(bid),即:
\begin{equation}
diff = price – bid
\end{equation}
绘制选手出价误差的 CDF 如下:
def make_cdf_from_list(values, name=''):
pmf = Pmf()
for value in values:
pmf.incr(value)
return pmf.make_cdf(name=name)
cdf_diff_1 = make_cdf_from_list(diffs_1, name='diff 1')
cdf_diff_2 = make_cdf_from_list(diffs_2, name='diff 2')
cdf_diff_1.plot_with([cdf_diff_2])
图像为:
可见出价误大部分是正的,即选手出价比真实价格低,这是可以理解的,因为如果出价超过了真实价格,会直接输掉比赛,所以选手跟倾向于出低价。
将选手建模为一种误差特性已知的价格猜测器,在这个模型中,模型的猜测误差(error)为展品价格(price)减去猜测价格(guess),即:
\begin{equation}
error = price – guess
\end{equation}
我们对这个模型的猜测误差(error)一无所知,但可以进行一些假设,认为猜测误差的分布和出价误差(diff)的分布相同,都是一个均值为 0 的高斯分布。
定义 Player 类如下:
class Player(object):
def __init__(self, prices, bids, diffs):
self.pdf_price = EstimatedPdf(prices)
self.cdf_diff = util.make_cdf_from_list(diffs)
self.pdf_error = GaussianPdf(mu=0, sigma=np.std(diffs))
self.prior = None
self.posterior = None
# ...
其中,self.pdf_price 是一个平滑的商品真实价格 PDF,self.cdf_diff 为出价误差的 CDF,self.pdf_error 为猜测误差的 PDF,它是一个均值为 0,标准差与出价误差相同的高斯分布。可以将 Player 类看做这样一个人,他总结了往期游戏中的商品的真实价格分布(self.pdf_price)和选手出价的误差(self.diff),认为自己猜测的误差(error)服从均值为零、标准差与往期选手出价误差相同的高斯分布。
4. 似然度
定义 Price 类如下:
class Price(Suite):
def __init__(self, pmf, player):
super().__init__(pmf)
self.player = player
def likelihood(self, data, hypo):
price = hypo
guess = data
error = price - guess
like = self.player.error_density(error)
return like
Price 类表示商品真实价格,__init__() 的参数 pmf 为商品价格的先验分布(由历史数据得来),参数 player 表示参赛选手(出价者),是上面的 Player 类的实例。likelihood() 用于计算当参赛选手给出 data 的猜测后,商品价格的假设 hypo 的似然度。在 likelihood() 中,通过 error = price - guess 得到猜测误差 error,error 的分布由 player 给定,通过 self.player.error_density(error) 得到 player 给出误差为 error 的猜测的概率,作为似然度。 player.error_density() 的定义如下:
class Player(object):
# ...
def error_density(self, error):
return self.pdf_error.density(error)
# ...
5. 更新
在 Player 中定义以下方法 make_beliefs(),计算当选手给出自己的猜测后,商品价格的后验分布:
class Player(object):
# ...
def make_beliefs(self, guess):
pmf = self.pmf_price()
self.prior = Price(pmf, self)
self.posterior = self.prior.copy()
self.posterior.update(guess)
n = 101
price_xs = np.linspace(0, 75000, n)
def pmf_price(self):
return self.pdf_price.make_pmf(self.price_xs)
# ...
pmf_price() 用于将 Player 中的历史商品真实价格的 PDF 转换成 PMF 的形式。在 make_beliefs() 中,在选手给出猜测 guess 后,设置 Player 中商品价格的先验概率 self.prior 与历史商品真实价格分布相同,后验概率 self.posterior 则由在先验概率上更新 data 猜测得到。
假设选手 1 在看到自己的商品后,给出了 20000 的猜测,使用如下代码计算先验和后验价格分布:
player_1 = Player(prices_1, bids_1, diffs_1)
player_1.make_beliefs(20000)
print("prior mean: {:.2f}, posterior mean: {:.2f}"
.format(player_1.prior.mean(), player_1.posterior.mean()))
player_1.plot()
输出为:
prior mean: 29482.74, posterior mean: 24382.43
图像为:
这里商品价格的先验概率分布与历史商品的真实价格分布相同,在出价前,商品的均值为 29482.74,这一数值单纯由往期节目的历史数据得来。选手 1 看到商品后,给出了 20000 的出价,说明他认为本期节目商品的价格明显低于历史平均水平,引入这一信息后,我们也预测商品价格更有可能比往期平均水平低,这也是图中后验分布要比先验分布更靠近低价格区间的原因。
6. 最优出价
有了后验分布后,就可以计算选手的最优出价了,这里的最优出价定义为使得预期收益最大化的出价。
定义 GainCalculator 类如下:
class GainCalculator(object):
def __init__(self, player, opponent):
self.player = player
self.opponent = opponent
def expected_gains(self, low=0, high=75000, n=101):
bids = np.linspace(low, high, n)
gains = [self.expected_gain(bid) for bid in bids]
return bids, gains
def expected_gain(self, bid):
suite = self.player.posterior
total = 0
for price, prob in suite.iter_items():
gain = self.gain(bid, price)
total += prob * gain
return total
def gain(self, bid, price):
if bid > price:
return 0
diff = price - bid
prob = self.prob_win(diff)
if diff <= 250:
return 2 * price * prob
else:
return price * prob
def prob_win(self, diff):
prob = self.opponent.prob_overbid() + self.opponent.prob_worse_than(diff)
return prob
GainCalculator 中,__init__() 的参数 player 为要计算最优出价的选手,opponent 为对手,二者都是 Player 类。
expected_gains() 计算 low 和 high 的区间上等间距的 n 次出价的预期收益。
每一次出价的收益由 expected_gain() 计算,它会迭代 player 中商品价格的后验概率,通过 gain() 计算对于每种商品价格的收益,求其期望。
gain() 的参数 bid 为出价,price 为商品价格。如果出价大于商品实际价格,则直接失败,收益为 0;如果出价不大于商品价格,则使用 prob_win() 计算 player 的出价胜过 opponent 的概率,然后计算收益与获胜概率的乘积。注意根据游戏规则,如果出价不高于商品价格,且误差小于 250,会赢得对方的奖品,这里为了简便,认为两个参赛选手的商品价值差不多,所以直接将收益翻倍。
prob_win() 计算在出价误差为 diff 时,player 胜过 opponent 的概率。player 胜过 opponent 包含两种情况:opponent 出价比商品实际价格高而直接失败,或者opponent 出价不高于商品实际价格,但误差比 player 的出价高。prob_overbid() 和 prob_worse_than() 在 Player 定义如下:
class Player(object):
# ...
def prob_overbid(self):
return self.cdf_diff.prob(-1)
def prob_worse_than(self, diff):
return 1 - self.cdf_diff.prob(diff)
# ...
注意 prob_overbid() 中的 self.cdf_diff 表示商品实际价格减去出价的差(diff)的 CDF,如果出价大于商品实际价格,则 diff 是一个负数,这里使用 -1 表示出价大于商品实际价格的情况,self.cdf_diff.prob(-1) 即为出价大于商品实际价格的概率。
在 Player 中,定义用于计算最佳出价的方法如下:
class Player(object):
# ...
def optimal_bid(self, guess, opponent):
self.make_beliefs(guess)
calc = GainCalculator(self, opponent)
bids, gains = calc.expected_gains()
gain, bid = max(zip(gains, bids))
return bid, gain
# ...
这里使用 calc.expected_gains() 计算了所有出价的收益,取收益最大的出价。
假设选手 1 出价为 20000,选手 2 出价为 40000,使用如下代码计算二人的收益:
player_1 = Player(prices_1, bids_1, diffs_1)
player_2 = Player(prices_2, bids_2, diffs_2)
bids_1, gains_1 = player_1.calc_bid_gain(20000, player_2)
print("player 1 max gain: {:.2f}, bid: {}".format(*max(zip(gains_1, bids_1))))
player_1 = Player(prices_1, bids_1, diffs_1)
player_2 = Player(prices_2, bids_2, diffs_2)
bids_2, gains_2 = player_2.calc_bid_gain(40000, player_1)
print("player 2 max gain: {:.2f}, bid: {}".format(*max(zip(gains_2, bids_2))))
plt.figure()
plt.plot(bids_1, gains_1, label="player 1")
plt.plot(bids_2, gains_2, label="player 2")
plt.legend()
输出为:
player 1 max gain: 16010.15, bid: 21000.0 player 2 max gain: 18881.10, bid: 31500.0
图像为:
可见选手 1 的最优出价为 21000 美元,高于其实际出价(20000);选手 2 的最优出价为 31500,低于其实际出价(40000)。



