Think Bayes Note 9: 价格竞猜

1. 价格竞猜

  在一个名为“正确的价格”的价格竞猜的电视节目中,会为参赛的两名选手各准备一组商品,两名选手要尝试猜测自己商品的价格。如果选手出价高于实际价格,则会直接输掉;如果选手出价低于实际价格,则出价误差较小的选手获胜;如果误差低于 250 美元,则该选手还会赢得对手的奖品。

  举例来说,纳撒尼尔和莱希娅两人参加了节目,纳撒尼尔要竞猜的商品包括洗碗机、酒柜、笔记本电脑和一辆汽车,他的出价为 26000 美元;莱希娅要的商品包括弹球机、电视游戏、台球桌和一次去巴拿马的旅行,她的出价为 21500 美元。纳撒尼尔的商品的实际价格为 25347 美元,他的出价比实际价格高,直接输掉了比赛;莱希娅的商品的实际价格为 21578 美元,她赢得了比赛,而且她的出价与实际价格间的误差少于 250 美元,她还赢得了纳撒尼尔的商品。

  根据这一场景,可以提出如下的问题:

  1. 在看到商品前,参赛者应当如何判断商品价格的先验分布;
  2. 看到商品后,参赛者应当如何修正自己的预期;
  3. 基于后验分布,参赛者应当如何出价。

2. 先验概率

  史蒂夫·吉收集了 2011 年到 2012 年期间这一节目中商品的价格和选手的出价(20112012),读取方法如下:

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_1prices_2 为两个选手的商品的实际价格,bids_1bids_2 为两个选手的出价,diffs_1diffs_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 得到猜测误差 errorerror 的分布由 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() 计算 lowhigh 的区间上等间距的 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)。