橄榄球预测的分层模型#

在这个例子中,我们将使用PyMC重现Baio和Blangiardo [2010]中描述的第一个模型。然后展示如何从后验预测中采样,以模拟从得分的进球中预测的锦标赛结果,这些进球是建模的量。

我们将论文的结果应用于六国锦标赛,这是一个意大利、爱尔兰、苏格兰、英格兰、法国和威尔士之间的比赛。

动机#

你对一个团队实力的估计取决于你对其他实力估计

例如,爱尔兰队比意大利队更强 - 但强多少呢?

2014年结果的来源是维基百科。我添加了后续的年份,2015年、2016年和2017年。手动从维基百科中提取。

  • 我们想要推断一个潜在参数——即仅基于球队的得分强度来推断其“实力”,而我们所有的只是他们的得分和结果,我们无法准确测量球队的“实力”。

  • 概率编程是一种用于建模这些潜在参数的卓越范式

  • 目标是建立一个2018年六国锦标赛的模型。

!date

import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
import pytensor.tensor as pt
import seaborn as sns

from matplotlib.ticker import StrMethodFormatter

%matplotlib inline
sáb 02 abr 2022 03:24:55 EEST
az.style.use("arviz-darkgrid")
plt.rcParams["figure.constrained_layout.use"] = False

这是一个橄榄球预测练习。因此,我们将输入一些数据。这些数据来自维基百科和BBC体育。

try:
    df_all = pd.read_csv("../data/rugby.csv", index_col=0)
except:
    df_all = pd.read_csv(pm.get_data("rugby.csv"), index_col=0)

我们想要推断什么?#

  • 我们想要推断生成我们观察到的数据(比分)的潜在参数(每个队伍的实力)。

  • 此外,我们知道比分是球队实力的一个有噪声的测量指标,因此理想情况下,我们希望有一个模型能够方便地量化我们对潜在实力的不确定性。

  • 通常我们不知道贝叶斯模型的具体形式,所以我们必须“估计”贝叶斯模型

  • 如果我们无法解决某个问题,可以近似解决它。

  • 马尔可夫链蒙特卡罗(MCMC)方法从后验分布中抽取样本。

  • 幸运的是,这个算法几乎可以应用于任何模型。

我们想要什么?#

  • 我们想要量化我们的不确定性

  • 我们还希望使用这个来生成一个模型

  • 我们希望答案是分布而不是点估计

可视化/探索性数据分析#

我们应该对这个数据集进行一些探索性数据分析。

这些图表应该相当直观,我们将观察诸如团队之间在得分方面的差异。

df_all.describe()
home_score away_score year
count 60.000000 60.000000 60.000000
mean 23.500000 19.983333 2015.500000
std 14.019962 12.911028 1.127469
min 0.000000 0.000000 2014.000000
25% 16.000000 10.000000 2014.750000
50% 20.500000 18.000000 2015.500000
75% 27.250000 23.250000 2016.250000
max 67.000000 63.000000 2017.000000
# Let's look at the tail end of this dataframe
df_all.tail()
home_team away_team home_score away_score year
55 Italy France 18 40 2017
56 England Scotland 61 21 2017
57 Scotland Italy 29 0 2017
58 France Wales 20 18 2017
59 Ireland England 13 9 2017

这里有一些我们不需要的东西。我们不需要年份来构建我们的模型。 但这可能是未来模型改进的一个方面。

首先让我们看看不同年份的分数差异。

df_all["difference"] = np.abs(df_all["home_score"] - df_all["away_score"])
(
    df_all.groupby("year")["difference"]
    .mean()
    .plot(
        kind="bar",
        title="Average magnitude of scores difference Six Nations",
        yerr=df_all.groupby("year")["difference"].std(),
    )
    .set_ylabel("Average (abs) point difference")
);
../../../_images/57717864299d85cf1955cf3751e75053d89e5663ae2d964ed7ac5726e92a14e7.png

我们可以看到标准误差很大。因此,我们无法对差异做出任何结论。 让我们逐个国家来看。

df_all["difference_non_abs"] = df_all["home_score"] - df_all["away_score"]

让我们首先看一下按年份细分的此数据的汇总透视表。

df_all.pivot_table("difference_non_abs", "home_team", "year")
year 2014 2015 2016 2017
home_team
England 7.000000 20.666667 7.500000 21.333333
France 6.666667 0.000000 -2.333333 4.000000
Ireland 28.000000 8.500000 17.666667 7.000000
Italy -21.000000 -31.000000 -23.500000 -33.666667
Scotland -11.000000 -12.000000 2.500000 16.666667
Wales 25.666667 1.000000 22.000000 4.000000

现在让我们首先按主队绘制这个图表,不考虑年份。

(
    df_all.pivot_table("difference_non_abs", "home_team")
    .rename_axis("Home_Team")
    .plot(kind="bar", rot=0, legend=False)
    .set_ylabel("Score difference Home team and away team")
);
../../../_images/312638f1ea94d2ee490d653b72da1808b07728889ed89fcb1b662ed735207b6b.png

你可以看到意大利和苏格兰的平均得分是负的。你还可以看到英格兰、爱尔兰和威尔士最近在国内是最强的队伍。

(
    df_all.pivot_table("difference_non_abs", "away_team")
    .rename_axis("Away_Team")
    .plot(kind="bar", rot=0, legend=False)
    .set_ylabel("Score difference Home team and away team")
);
../../../_images/073493a914f5a6731dfc88ac18337e2370a1edbce1018a08c4b5814ebeee5b56.png

这表明意大利、苏格兰和法国在客场比赛中表现都不佳。 英格兰在客场比赛中受影响最小。这种总体观点没有考虑到各队的实力。

让我们更详细地看一下全年得分差异平均值的时间序列图。

我们观察到团队行为的一些变化,并且我们也看到意大利是一个表现不佳的团队。

g = sns.FacetGrid(df_all, col="home_team", col_wrap=2, height=5)
g.map(sns.scatterplot, "year", "difference_non_abs")
g.fig.autofmt_xdate()
../../../_images/939e2b380b884b82d7217fdcc914c31020860ff77da6abf85c6f45f347959e44.png
g = sns.FacetGrid(df_all, col="away_team", col_wrap=2, height=5)
g = g.map(plt.scatter, "year", "difference_non_abs").set_axis_labels("Year", "Score Difference")
g.fig.autofmt_xdate()
../../../_images/566216cf17d8dd5ac93c5f470101536d284162626dd9fc60f22e92a957fedd24.png

你可以在这里看到一些有趣的事情,比如威尔士在2015年客场表现出色。 在那一年,他们在客场赢得了三场比赛,并且在客场对阵意大利时以大约40分的优势获胜。

所以现在我们对数据有了一定的了解,我们可以继续描述模型。

我们的“生成故事”有哪些假设?#

  • 我们知道橄榄球六国赛只有6支球队——它们各自与其他球队比赛一次

  • 我们有过去几年的数据

  • 我们还知道,在体育比赛中,得分通常被建模为泊松分布

  • 我们认为主场优势在体育运动中是一个强有力的影响因素

模型.#

联赛由总共T= 6支球队组成,每支球队在一个赛季中与其他球队各比赛一次。我们用\(y_{g1}\)\(y_{g2}\)分别表示赛季中第g场比赛(共15场比赛)中主队和客队得分的点数。

The vector of observed counts \(\mathbb{y} = (y_{g1}, y_{g2})\) is modelled as independent Poisson: \(y_{gi}| \theta_{gj} \tilde\;\; Poisson(\theta_{gj})\) where the theta parameters represent the scoring intensity in the g-th game for the team playing at home (j=1) and away (j=2), respectively.

我们根据在统计文献中广泛使用的一种公式对这些参数进行建模,假设一个对数线性随机效应模型:

\[log \theta_{g1} = home + att_{h(g)} + def_{a(g)} \]
\[log \theta_{g2} = att_{a(g)} + def_{h(g)}\]

  • 参数 home 代表主场球队的优势,我们假设这种效应对于所有球队和整个赛季都是恒定的

  • 得分强度由所涉及的两支球队的进攻和防守能力共同决定,分别由参数att和def表示

  • 相反,对于每个 t = 1, …, T,团队特定效应被建模为从共同分布中可交换的:

  • \(att_{t} \; \tilde\;\; 正态分布(\mu_{att},\tau_{att})\)\(def_{t} \; \tilde\;\; 正态分布(\mu_{def},\tau_{def})\)

  • 我们在上面进行了一些数据处理和调整,使其更整洁,以便于我们的模型使用。

  • 对客队得分和主队得分取对数是体育分析文献中的一个标准技巧

模型的构建#

我们现在在 PyMC 中构建模型,指定全局参数、团队特定参数和似然函数

plt.rcParams["figure.constrained_layout.use"] = True
home_idx, teams = pd.factorize(df_all["home_team"], sort=True)
away_idx, _ = pd.factorize(df_all["away_team"], sort=True)
coords = {"team": teams}
with pm.Model(coords=coords) as model:
    # constant data
    home_team = pm.ConstantData("home_team", home_idx, dims="match")
    away_team = pm.ConstantData("away_team", away_idx, dims="match")

    # global model parameters
    home = pm.Normal("home", mu=0, sigma=1)
    sd_att = pm.HalfNormal("sd_att", sigma=2)
    sd_def = pm.HalfNormal("sd_def", sigma=2)
    intercept = pm.Normal("intercept", mu=3, sigma=1)

    # team-specific model parameters
    atts_star = pm.Normal("atts_star", mu=0, sigma=sd_att, dims="team")
    defs_star = pm.Normal("defs_star", mu=0, sigma=sd_def, dims="team")

    atts = pm.Deterministic("atts", atts_star - pt.mean(atts_star), dims="team")
    defs = pm.Deterministic("defs", defs_star - pt.mean(defs_star), dims="team")
    home_theta = pt.exp(intercept + home + atts[home_idx] + defs[away_idx])
    away_theta = pt.exp(intercept + atts[away_idx] + defs[home_idx])

    # likelihood of observed data
    home_points = pm.Poisson(
        "home_points",
        mu=home_theta,
        observed=df_all["home_score"],
        dims=("match"),
    )
    away_points = pm.Poisson(
        "away_points",
        mu=away_theta,
        observed=df_all["away_score"],
        dims=("match"),
    )
    trace = pm.sample(1000, tune=1500, cores=4)
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
/home/oriol/miniconda3/envs/arviz/lib/python3.9/site-packages/pymc/pytensorf.py:1005: UserWarning: The parameter 'updates' of pytensor.function() expects an OrderedDict, got <class 'dict'>. Using a standard dictionary here results in non-deterministic behavior. You should use an OrderedDict if you are using Python 2.7 (collections.OrderedDict for older python), or use a list of (shared, update) pairs. Do not just convert your dictionary to this type before the call as the conversion will still be non-deterministic.
  pytensor_function = pytensor.function(
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [home, sd_att, sd_def, intercept, atts_star, defs_star]
100.00% [10000/10000 00:24<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_500 tune and 1_000 draw iterations (6_000 + 4_000 draws total) took 25 seconds.
  • 我们指定了模型和似然函数

  • 所有这些都在底层运行在一个 PyTensor 图上

az.plot_trace(trace, var_names=["intercept", "home", "sd_att", "sd_def"], compact=False);
../../../_images/b474d67ee573b73a8cf827149ca022cdae286f70455826b645a3a1082e5d173f.png

让我们应用良好的统计工作流程实践,并查看各种评估指标,以确定我们的NUTS采样器是否收敛。

az.plot_energy(trace, figsize=(6, 4));
../../../_images/85e7cddd04d03d973e2334552d62253b9694f250e44021e5f07e46003106eee6.png
az.summary(trace, kind="diagnostics")
mcse_mean mcse_sd ess_bulk ess_tail r_hat
home 0.001 0.001 2694.0 2250.0 1.0
intercept 0.001 0.000 2743.0 2455.0 1.0
atts_star[England] 0.004 0.003 1243.0 1121.0 1.0
atts_star[France] 0.004 0.003 1247.0 1111.0 1.0
atts_star[Ireland] 0.004 0.003 1208.0 1160.0 1.0
atts_star[Italy] 0.004 0.003 1352.0 1375.0 1.0
atts_star[Scotland] 0.004 0.003 1318.0 1178.0 1.0
atts_star[Wales] 0.004 0.003 1252.0 1122.0 1.0
defs_star[England] 0.007 0.005 1110.0 899.0 1.0
defs_star[France] 0.007 0.006 1091.0 876.0 1.0
defs_star[Ireland] 0.007 0.005 1113.0 881.0 1.0
defs_star[Italy] 0.007 0.006 1075.0 846.0 1.0
defs_star[Scotland] 0.007 0.006 1081.0 854.0 1.0
defs_star[Wales] 0.007 0.006 1098.0 889.0 1.0
sd_att 0.004 0.003 1767.0 1709.0 1.0
sd_def 0.006 0.004 1762.0 1662.0 1.0
atts[England] 0.001 0.000 5489.0 3204.0 1.0
atts[France] 0.001 0.000 5441.0 3286.0 1.0
atts[Ireland] 0.001 0.000 5257.0 3260.0 1.0
atts[Italy] 0.001 0.001 5157.0 2957.0 1.0
atts[Scotland] 0.001 0.001 4926.0 3003.0 1.0
atts[Wales] 0.001 0.000 4537.0 3306.0 1.0
defs[England] 0.001 0.001 4779.0 3293.0 1.0
defs[France] 0.001 0.001 4183.0 2917.0 1.0
defs[Ireland] 0.001 0.001 4766.0 3227.0 1.0
defs[Italy] 0.001 0.000 4419.0 3561.0 1.0
defs[Scotland] 0.001 0.000 4068.0 3290.0 1.0
defs[Wales] 0.001 0.001 4177.0 3164.0 1.0

我们的模型已经很好地收敛了,\(\hat{R}\) 看起来不错。

让我们来看一些统计数据,只是为了验证我们的模型是否返回了正确的属性。我们可以看到一些球队比其他球队更强。这与我们对攻击的预期一致。

trace_hdi = az.hdi(trace)
trace_hdi["atts"]
<xarray.DataArray 'atts' (team: 6, hdi: 2)>
array([[ 0.18302232,  0.33451681],
       [-0.16672247,  0.00056686],
       [ 0.02480029,  0.18070957],
       [-0.44275554, -0.2336128 ],
       [-0.20865349, -0.03104828],
       [ 0.09821099,  0.25031702]])
Coordinates:
  * team     (team) <U8 'England' 'France' 'Ireland' 'Italy' 'Scotland' 'Wales'
  * hdi      (hdi) <U6 'lower' 'higher'
trace.posterior["atts"].median(("chain", "draw"))
<xarray.DataArray 'atts' (team: 6)>
array([ 0.25676819, -0.08392998,  0.10809197, -0.33498374, -0.11633869,
        0.17182582])
Coordinates:
  * team     (team) <U8 'England' 'France' 'Ireland' 'Italy' 'Scotland' 'Wales'

结果#

从上述内容中,我们可以开始理解攻击强度和防守强度的不同分布。 这些是概率估计,帮助我们更好地理解体育分析中的不确定性

_, ax = plt.subplots(figsize=(12, 6))

ax.scatter(teams, trace.posterior["atts"].median(dim=("chain", "draw")), color="C0", alpha=1, s=100)
ax.vlines(
    teams,
    trace_hdi["atts"].sel({"hdi": "lower"}),
    trace_hdi["atts"].sel({"hdi": "higher"}),
    alpha=0.6,
    lw=5,
    color="C0",
)
ax.set_xlabel("Teams")
ax.set_ylabel("Posterior Attack Strength")
ax.set_title("HDI of Team-wise Attack Strength");
../../../_images/cb76b6c036519b7b7bb69d9e0eceb6ec43c5c788a8d1135119dfad5854153e08.png

这是贝叶斯建模的强大之处之一,我们可以对某些估计进行不确定性量化。 我们为不同国家的攻击强度提供了一个贝叶斯可信区间。

我们可以看到爱尔兰、威尔士和英格兰之间有重叠,这是你所期望的,因为这些球队在近年都曾获胜。

意大利远远落后于其他国家——这正是我们所预期的,苏格兰和法国之间存在重叠,这似乎是合理的。

我们可能希望在这里添加一些效果,比如更强烈地加权最近的结果。 然而,这将是一个复杂得多的模型。

# subclass arviz labeller to omit the variable name
class TeamLabeller(az.labels.BaseLabeller):
    def make_label_flat(self, var_name, sel, isel):
        sel_str = self.sel_to_str(sel, isel)
        return sel_str
ax = az.plot_forest(trace, var_names=["atts"], labeller=TeamLabeller())
ax[0].set_title("Team Offense");
../../../_images/b486975aa2d04f14169fc13ea453406cdd4bd9f2994e88a2cfa06b22d6db6445.png
ax = az.plot_forest(trace, var_names=["defs"], labeller=TeamLabeller())
ax[0].set_title("Team Defense");
../../../_images/9d8790cfd25fc774dae2dfdf491ccd59386a3f5949601ed4970934da9d3a7884.png

像爱尔兰和英格兰这样的优秀球队在防守方面有很强的负面影响。这也是我们所期望的。我们期望我们的强队在进攻方面有很强的正面影响,在防守方面有很强的负面影响。

我们正在使用的这种查看参数并对其进行检查的方法是良好统计工作流程的一部分。 我们也认为我们的先验可能可以更好地指定。然而,这超出了本文的范围。 我们建议您访问Robust Statistical Workflow with RStan以获得关于“统计工作流程”的良好讨论。

让我们做一些其他的图表。这样我们可以看到我们的防御效果的范围。 我也会在下面打印出队伍,只是为了参考

az.plot_posterior(trace, var_names=["defs"]);
../../../_images/ca5591b1694805ce8fedbc433d47889df91961a26a5093dd453be480e3bb7168.png

我们可以看到爱尔兰的平均值是-0.39,这意味着我们预计爱尔兰会有强大的防守。 这也是我们所期望的,爱尔兰通常即使在输掉比赛时也不会输掉50分。 我们可以看到94%的HDI在-0.491和-0.28之间。

与意大利相比,我们看到一个强烈的正效应,平均值为0.58,HDI为0.51和0.65。这意味着我们预计意大利会比它得分的要多失很多分。鉴于意大利经常输掉30 - 60分,这似乎是正确的。

我们在这里也看到,这告诉我们还可以引入哪些其他先验。我们可以引入某种世界排名作为先验。

截至2017年12月,橄榄球排名显示英格兰排名世界第二,爱尔兰第三,苏格兰第五,威尔士第七,法国第九,意大利第十四。我们可以将这些数据纳入模型中,并解释意大利与其他许多球队之间的差距。

现在让我们模拟在总共4000次模拟中谁会获胜,每次模拟对应后验分布中的一个样本。

with model:
    pm.sample_posterior_predictive(trace, extend_inferencedata=True)
pp = trace.posterior_predictive
const = trace.constant_data
team_da = trace.posterior.team
/home/oriol/miniconda3/envs/arviz/lib/python3.9/site-packages/pymc/pytensorf.py:1005: UserWarning: The parameter 'updates' of pytensor.function() expects an OrderedDict, got <class 'dict'>. Using a standard dictionary here results in non-deterministic behavior. You should use an OrderedDict if you are using Python 2.7 (collections.OrderedDict for older python), or use a list of (shared, update) pairs. Do not just convert your dictionary to this type before the call as the conversion will still be non-deterministic.
  pytensor_function = pytensor.function(
100.00% [4000/4000 00:00<00:00]

后验预测样本包含了每场比赛中每个球队的进球数。我们根据进球数作为观测变量,模拟了得分和防守能力。

我们的目标现在是看看谁赢得了比赛,这样我们就可以估计每支球队赢得整个比赛的可能性。从那里我们需要将进球数转换为积分:

# fmt: off
pp["home_win"] = (
    (pp["home_points"] > pp["away_points"]) * 3     # home team wins and gets 3 points
    + (pp["home_points"] == pp["away_points"]) * 2  # tie -> home team gets 2 points
)
pp["away_win"] = (
    (pp["home_points"] < pp["away_points"]) * 3
    + (pp["home_points"] == pp["away_points"]) * 2
)
# fmt: on

然后添加每个团队在整个比赛过程中收集的积分:

groupby_sum_home = pp.home_win.groupby(team_da[const.home_team]).sum()
groupby_sum_away = pp.away_win.groupby(team_da[const.away_team]).sum()

pp["teamscores"] = groupby_sum_home + groupby_sum_away

并最终生成所有团队在每次4000次模拟中的排名。由于我们的数据存储在InferenceData类中的xarray对象中,我们将使用xarray-einstats

from xarray_einstats.stats import rankdata

pp["rank"] = rankdata(-pp["teamscores"], dims="team", method="min")
pp[["rank"]].sel(team="England")
<xarray.Dataset>
Dimensions:  (chain: 4, draw: 1000)
Coordinates:
  * chain    (chain) int64 0 1 2 3
  * draw     (draw) int64 0 1 2 3 4 5 6 7 8 ... 992 993 994 995 996 997 998 999
    team     <U7 'England'
Data variables:
    rank     (chain, draw) int64 2 1 2 2 2 1 1 1 2 1 1 ... 1 1 1 1 2 1 3 1 2 2 1
Attributes:
    created_at:                 2022-04-02T00:25:59.622442
    arviz_version:              0.12.0
    inference_library:          pymc
    inference_library_version:  4.0.0b6

如您所见,我们现在为每个团队收集了4000个介于1到6之间的整数,1表示他们赢得了比赛。我们可以使用一个直方图,其区间边缘为半整数,来计算并归一化每个团队在每个位置完成的次数:

from xarray_einstats.numba import histogram

bin_edges = np.arange(7) + 0.5
data_sim = (
    histogram(pp["rank"], dims=("chain", "draw"), bins=bin_edges, density=True)
    .rename({"bin": "rank"})
    .assign_coords(rank=np.arange(6) + 1)
)

现在我们已经将数据缩减为一个二维数组,我们将把它转换为一个 pandas DataFrame,这现在是一个更适合处理我们数据的选择了:

idx_dim, col_dim = data_sim.dims
sim_table = pd.DataFrame(data_sim, index=data_sim[idx_dim], columns=data_sim[col_dim])
fig, ax = plt.subplots(figsize=(8, 4))
ax = sim_table.T.plot(kind="barh", ax=ax)
ax.xaxis.set_major_formatter(StrMethodFormatter("{x:.1%}"))
ax.set_xlabel("Rank-wise Probability of results for all six teams")
ax.set_yticklabels(np.arange(1, 7))
ax.set_ylabel("Ranks")
ax.invert_yaxis()
ax.legend(loc="best", fontsize="medium");
../../../_images/2167797281ecffd3c33297dc946f0965ce96a22641a54d06cff2cc15a46abc29.png

根据这个模型,我们看到爱尔兰在60%的情况下以最高分结束,英格兰在45%的情况下以最高分结束,而威尔士在约10%的情况下以最高分结束。(请注意,这些概率的总和不到100%,因为表格顶部出现平局的可能性不为零。)

作为一名爱尔兰橄榄球迷 - 我喜欢这个模型。然而,它指出了一些收缩和偏差的问题。由于最近的表现表明英格兰将获胜。

尽管如此,这个模型的重点是说明如何将分层模型应用于体育分析问题,并展示PyMC的强大功能。

协变量#

我们应该对变量进行一些探索

az.plot_pair(
    trace,
    var_names=["atts"],
    kind="scatter",
    divergences=True,
    textsize=25,
    marginals=True,
),
figsize = (10, 10)
../../../_images/2b9ecbddc564005675a58a17cb39290ef699f760f31133b448f2450fc187a2d8.png

我们观察到这些协变量之间并没有太多的相关性,除了像意大利这样的弱队在这些变量上有一个更负面的分布。 尽管如此,这是一个很好的方法来深入了解这些变量的行为。

作者#

参考资料#

[1] (1,2)

Gianluca Baio 和 Marta Blangiardo。用于预测足球结果的贝叶斯分层模型。应用统计学杂志,37(2):253–264, 2010。

水印#

%load_ext watermark
%watermark -n -u -v -iv -w -p xarray,aeppl,numba,xarray_einstats
Last updated: Sat Apr 02 2022

Python implementation: CPython
Python version       : 3.9.10
IPython version      : 8.0.1

xarray         : 2022.3.0
aeppl          : 0.0.27
numba          : 0.55.1
xarray_einstats: 0.2.0

matplotlib: 3.5.1
numpy     : 1.21.5
pandas    : 1.4.1
seaborn   : 0.11.2
pymc      : 4.0.0b6
pytensor    : 2.5.1
arviz     : 0.12.0

Watermark: 2.3.0