Лабораторная работа 1: Fifty Shades of Coffee
sprint 1
Авторы: Петров Мирон (Mironiks007), Каплунова Анна (kaplunovaania), Племяшов Ярослав (losos-1), Швалов Тихон (stenkich), Ильин Матвей (wizrdj)
В этой работе исследуем три показателя кофе: влажность зёрен, кислотность и полноту напитка. Хотим посмотреть, как распределены их значения, насколько они связаны между собой и как меняются оценки среднего и дисперсии при увеличении выборки
Оглавление
- Данные и единицы измерения
- Почему показатели — случайные величины · критерий 1
- Подготовка данных и EDA · критерий 4
- Три случайные величины
- Математическая база распределений · критерий 6
- Связи между показателями · критерии 2 и 7
- Состоятельность оценок · критерий 3
- Генеральная совокупность и отбор · критерий 5
- Интерактивный генератор выборок · доп. задание
- Итоги и выводы · выводы · нейросеть · участники · источники
Файл coffee_ratings.csv лежит рядом с ноутбуком.
1. Данные и единицы измерения
Используем датасет Coffee ratings из TidyTuesday. 1339 записей x 43 столбца. Данные собраны с сайта Coffee Quality Institute в январе 2018 года и опубликованы в Coffee Quality Database
Для анализа выбрали столбцы:
| Показатель | Столбец | Единицы |
|---|---|---|
| влажность | moisture | доли |
| кислотность | acidity | дегустационный балл |
| полнота напитка | body | дегустационный балл |
</> показать код
#@title 1. Данные и единицы измерения
from pathlib import Path
import hashlib
import platform
import numpy as np
import pandas as pd
import scipy
from scipy import stats, optimize
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
SEED = 42
ALPHA = 0.05
REPOSITORY = "Mironiks007/fifty-shades-of-coffee-stat"
NOTEBOOK_PATH = "stat-lab1-s1.ipynb" # целевой путь после загрузки в корень репозитория
NOTEBOOK_URL = f"https://github.com/{REPOSITORY}/blob/main/{NOTEBOOK_PATH}"
TEAM = [
{"name": "Петров Мирон", "github": "Mironiks007", "cat_id": 502585, "coeff": 0.5},
{"name": "Каплунова Анна", "github": "kaplunovaania", "cat_id": 501572, "coeff": 0.5},
]
columns = ["moisture", "acidity", "body"]
labels = {"moisture": "Влажность (доля; 0.11 = 11%)", "acidity": "Кислотность (балл)", "body": "Полнота (балл)"}
sns.set_theme(style="whitegrid")
# единый стиль графиков
PALETTE = {"main": "#5cb5a6", "accent": "#c8884a", "ink": "#eceae6"} # dark restyle: fills=amber(accent), lines=teal(main), ink=light
NAMES = {"moisture": "Влажность", "acidity": "Кислотность", "body": "Полнота"}
ESTIMATOR_RU = {"mean": "среднее", "variance": "дисперсия", "var": "дисперсия", "std": "СКО"}
SAMPLING_RU = {"with replacement": "с возвращением", "without replacement": "без возвращения"}
plt.rcParams.update({
"figure.dpi": 110, "figure.titlesize": 14, "figure.titleweight": "bold",
"axes.titlesize": 12, "axes.labelsize": 11, "legend.fontsize": 9,
"axes.prop_cycle": matplotlib.cycler(color=[PALETTE["main"], PALETTE["accent"], PALETTE["ink"]]),
})
pd.set_option("display.max_columns", 20)
# --- наш тёмный векторный стиль «Тёмная обжарка» (перебивает светлую тему seaborn) ---
plt.rcParams.update({
"font.family": "serif",
"font.serif": ["DejaVu Serif"],
"figure.facecolor": "#141418",
"figure.dpi": 120,
"axes.facecolor": "#141418",
"savefig.facecolor": "#141418",
"text.color": "#eceae6",
"axes.labelcolor": "#9a97a0",
"axes.titlecolor": "#eceae6",
"figure.titlesize": 14, "figure.titleweight": "bold",
"xtick.color": "#9a97a0",
"ytick.color": "#9a97a0",
"axes.edgecolor": "#6d6b75",
"grid.color": "#30303a",
"grid.alpha": 1,
"axes.axisbelow": True,
"legend.facecolor": "#1e1e24",
"legend.edgecolor": "#6d6b75",
"legend.labelcolor": "#eceae6",
# boxplot: без явных props линии по умолчанию тёмные — поднимаем на тёмном фоне
"boxplot.boxprops.color": "#9a97a0",
"boxplot.whiskerprops.color": "#9a97a0",
"boxplot.capprops.color": "#9a97a0",
"boxplot.medianprops.color": "#c8884a",
"boxplot.flierprops.markeredgecolor": "#9a97a0",
})
# тёмный экспорт + вектор (как в s2): инлайн-картинки с нашим фоном
try:
ip = get_ipython()
ip.run_line_magic("config", "InlineBackend.figure_format = 'retina'")
ip.run_line_magic("config", "InlineBackend.print_figure_kwargs = {'facecolor': '#141418', 'bbox_inches': 'tight'}")
except Exception:
pass
def show_md(text):
try:
from IPython.display import display, Markdown
display(Markdown(text))
except ImportError:
print(text)
def show_table(frame, digits=5):
try:
from IPython.display import display
display(frame.round(digits))
except ImportError:
print(frame.round(digits).to_string())
def fmt(value, digits=4):
return f"{float(value):.{digits}f}".replace(".", ",")
def holm_adjust(p_values):
p = np.asarray(p_values, dtype=float)
order = np.argsort(p)
adjusted = np.empty(len(p))
adjusted[order] = np.minimum(1, np.maximum.accumulate(p[order] * (len(p)-np.arange(len(p)))))
return adjusted
expected_sha256 = "ae6fdd58f4a7f77397c11c2657e22be8aa8f746bdc5a74cf479e981bd3c25d5e"
candidates = [Path("coffee_ratings.csv"), Path("data/coffee_ratings.csv"),
Path("../coffee_ratings.csv"), Path("../data/coffee_ratings.csv")]
local_path = next((p for p in candidates if p.is_file()), None)
if local_path is None:
from urllib.request import urlopen
urls = [f"https://raw.githubusercontent.com/{REPOSITORY}/main/coffee_ratings.csv",
"https://raw.githubusercontent.com/rfordatascience/tidytuesday/main/data/2020/2020-07-07/coffee_ratings.csv"]
download_errors = []
for url in urls:
try:
with urlopen(url, timeout=30) as response:
payload = response.read()
if hashlib.sha256(payload).hexdigest() != expected_sha256:
raise ValueError("Контрольная сумма CSV не совпала")
local_path = Path("coffee_ratings.csv")
local_path.write_bytes(payload)
break
except Exception as error:
download_errors.append(f"{url}: {type(error).__name__}: {error}")
if local_path is None:
raise FileNotFoundError("Положите исходный coffee_ratings.csv рядом с ноутбуком. " + " | ".join(download_errors))
assert hashlib.sha256(local_path.read_bytes()).hexdigest() == expected_sha256, "Это другой CSV: требуется повторная проверка происхождения"
raw = pd.read_csv(local_path)
assert set(columns + ["species"]).issubset(raw.columns)
show_table(raw["species"].value_counts().to_frame("records"))| records | |
|---|---|
| species | |
| Arabica | 1311 |
| Robusta | 28 |
2. Почему показатели — случайные величины (критерий 1)
Влажность зависит от сушки, условий хранения и самой пробы. При измерении также возникает погрешность. Поэтому до проведения измерения можем оценить ожидаемое значение, но не знаем точный результат для конкретного образца. Например, в руководстве University of Hawai‘i описаны влияние среды на влажность и вариация показаний влагомера
Например, при определении влажности по потере массы используют формулу $w=(m_{до}-m_{после})/m_{до}$. Чтобы ей воспользоваться, сначала нужно измерить обе массы. Масса же является случайной величиной.
Кислотность и полнота зависят от образца, дегустатора и условий оценивания. Для этих показателей используем модель
\
$R_{ij}=\mu_i+a_j+\varepsilon_{ij}$
\
где $\mu_i$ - средняя оценка образца, $a_j$ - впечатление дегустатора, а $\varepsilon_{ij}$ - вариация конкретной дегустации. В датасете нет размеченных повторных оценок одних образцов разными экспертами, поэтому не разделяем эти вклады численно
3. Подготовка данных и EDA (критерий 4)
- оставляем только Arabica, чтобы анализировать один вид кофе
- убираем пропуски и строки с нулевыми значениями выбранных показателей
Нулевая влажность выглядит сомнительно но окэээээй, но её значение в документации не объясняется, поэтому дальше отдельно проверяем, как этот отбор влияет на результаты
Все ненулевые крайние значения сохраняем. Для сравнения также рассматриваем группу с нулевой влажностью и общий набор с положительными дегустационными оценками
</> показать код
#@title 3. Подготовка данных и EDA (критерий 4)
arabica = raw.loc[raw["species"].eq("Arabica")].copy()
complete = arabica.dropna(subset=columns)
sensory_base = complete.loc[complete[["acidity", "body"]].gt(0).all(axis=1)].copy()
data = sensory_base.loc[sensory_base["moisture"].gt(0)].copy()
assert len(data) >= 1000
assert data[columns].gt(0).all().all()
flow_table = pd.DataFrame([
["Исходный файл", len(raw)], ["Arabica", len(arabica)],
["Полные три показателя", len(complete)], ["Положительные сенсорные баллы", len(sensory_base)],
["Основной анализ: также положительная влажность", len(data)],
], columns=["stage", "n"])
show_table(flow_table.set_index("stage"), 0)
show_table(pd.DataFrame({"missing": raw[columns].isna().sum(), "zero": raw[columns].eq(0).sum()}), 0)
show_md(f"После отбора исключили **{len(arabica)-len(data)} из {len(arabica)}** записей Arabica ({fmt(100*(len(arabica)-len(data))/len(arabica),1)}%). Далее проверяем, как это повлияло на результаты.")| n | |
|---|---|
| stage | |
| Исходный файл | 1339 |
| Arabica | 1311 |
| Полные три показателя | 1311 |
| Положительные сенсорные баллы | 1310 |
| Основной анализ: также положительная влажность | 1058 |
| missing | zero | |
|---|---|---|
| moisture | 0 | 264 |
| acidity | 0 | 1 |
| body | 0 | 1 |
После отбора исключили 253 из 1311 записей Arabica (19,3%). Далее проверяем, как это повлияло на результаты.
3.1 Как исключение нулей влияет на результаты
Сравниваем записи с нулевой и положительной влажностью по кислотности, полноте и странам происхождения. Так можем увидеть, насколько различаются состав и оценки этих групп
Для самой влажности рассматриваем два варианта:
- оставить нули как записанные значения
- считать их неизвестными измерениями
Во втором случае из ограничения $0\le w\le1$ получаем возможные границы среднего по всему набору с положительными дегустационными баллами. Этот диапазон показывает неопределённость из-за неизвестных значений, он не является доверительным интервалом
</> показать код
#@title 3.1 Как исключение нулей влияет на результаты
zero_moisture = sensory_base.loc[sensory_base["moisture"].eq(0)].copy()
sensitivity_rows = []
for label, subset in [("positive moisture", data), ("zero moisture only", zero_moisture), ("zeros retained", sensory_base)]:
sensitivity_rows.append({"sample": label, "n": len(subset), "moisture_recorded_mean": subset["moisture"].mean(),
"acidity_mean": subset["acidity"].mean(), "body_mean": subset["body"].mean(),
"acidity_body_rho": stats.spearmanr(subset["acidity"], subset["body"]).statistic})
sensitivity_table = pd.DataFrame(sensitivity_rows).set_index("sample")
show_table(sensitivity_table)
country_selection = sensory_base.assign(country=sensory_base["country_of_origin"].fillna("unknown"),
zero=sensory_base["moisture"].eq(0)).groupby("country").agg(
n=("zero","size"), zeros=("zero","sum"))
country_selection["zero_fraction"] = country_selection["zeros"] / country_selection["n"]
show_table(country_selection.sort_values("n", ascending=False).head(10))
selection_effects = pd.DataFrame({
"positive_mean": data[["acidity","body"]].mean(), "zero_group_mean": zero_moisture[["acidity","body"]].mean(),
"all_sensory_mean": sensory_base[["acidity","body"]].mean(),
})
selection_effects["difference_zero_minus_positive"] = selection_effects["zero_group_mean"] - selection_effects["positive_mean"]
selection_effects["difference_in_all_sensory_SD"] = selection_effects["difference_zero_minus_positive"] / sensory_base[["acidity","body"]].std(ddof=1)
show_table(selection_effects)
missing_moisture_n = len(zero_moisture)
mean_bound_low = data["moisture"].sum() / len(sensory_base)
mean_bound_high = (data["moisture"].sum() + missing_moisture_n) / len(sensory_base)
show_md(f"Если оставляем нулевую влажность, в анализ входит **{len(sensory_base)}** строк. Корреляция кислотности и полноты составляет **{fmt(sensitivity_table.loc['zeros retained','acidity_body_rho'],3)}**, после отбора - **{fmt(sensitivity_table.loc['positive moisture','acidity_body_rho'],3)}**. Направление и величина связи остаются близкими. Если считать {missing_moisture_n} нулей неизвестными измерениями, получаем границы среднего [{fmt(mean_bound_low)}; {fmt(mean_bound_high)}]. Среднее по записям с положительной влажностью равно **{fmt(data['moisture'].mean())}**, или **{fmt(100*data['moisture'].mean(),2)}%**.")| n | moisture_recorded_mean | acidity_mean | body_mean | acidity_body_rho | |
|---|---|---|---|---|---|
| sample | |||||
| positive moisture | 1058 | 0.11000 | 7.52662 | 7.50303 | 0.62357 |
| zero moisture only | 252 | 0.00000 | 7.59028 | 7.60925 | 0.53088 |
| zeros retained | 1310 | 0.08884 | 7.53886 | 7.52347 | 0.61543 |
| n | zeros | zero_fraction | |
|---|---|---|---|
| country | |||
| Mexico | 236 | 14 | 0.05932 |
| Colombia | 183 | 77 | 0.42077 |
| Guatemala | 181 | 7 | 0.03867 |
| Brazil | 132 | 31 | 0.23485 |
| Taiwan | 75 | 18 | 0.24000 |
| United States (Hawaii) | 73 | 27 | 0.36986 |
| Honduras | 52 | 0 | 0.00000 |
| Costa Rica | 51 | 11 | 0.21569 |
| Ethiopia | 44 | 5 | 0.11364 |
| Tanzania, United Republic Of | 40 | 1 | 0.02500 |
| positive_mean | zero_group_mean | all_sensory_mean | difference_zero_minus_positive | difference_in_all_sensory_SD | |
|---|---|---|---|---|---|
| acidity | 7.52662 | 7.59028 | 7.53886 | 0.06366 | 0.19900 |
| body | 7.50303 | 7.60925 | 7.52347 | 0.10621 | 0.36234 |
Если оставляем нулевую влажность, в анализ входит 1310 строк. Корреляция кислотности и полноты составляет 0,615, после отбора - 0,624. Направление и величина связи остаются близкими. Если считать 252 нулей неизвестными измерениями, получаем границы среднего [0,0888; 0,2812]. Среднее по записям с положительной влажностью равно 0,1100, или 11,00%.
3.2 Методы анализа
Для каждого показателя рассчитываем среднее $\bar x$, медиану, исправленную дисперсию $s^2=\sum(x_i-\bar x)^2/(n-1)$, стандартное отклонение, квартили и асимметрию. Затем строим гистограмму, эмпирическую функцию распределения, boxplot и Q–Q-график. По правилу $1.5IQR$ отмечаем крайние значения, но оставляем их в расчётах
Тест Д’Агостино–Пирсона используем для первоначальной оценки нормальности. Поскольку значения округлены, более подробную проверку распределений с учётом округления проводим в критерии 6.
Для среднего строим приближённый интервал $\bar x\pm t_{0.975,n-1}s/\sqrt n$ и процентильный bootstrap-интервал. В этих расчётах предполагаем независимость строк. Позже проверяем устойчивость результатов с помощью повторного отбора по фермам.
</> показать код
#@title 3.2 Методы анализа
normality_rows = []
interval_rows = []
for column in columns:
values = data[column].to_numpy()
normality = stats.normaltest(values)
normality_rows.append({"variable": column, "statistic": normality.statistic, "p_value": normality.pvalue})
n = len(values)
mean = values.mean()
standard_error = values.std(ddof=1) / np.sqrt(n)
t_low, t_high = stats.t.interval(0.95, df=n-1, loc=mean, scale=standard_error)
bootstrap_rng = np.random.default_rng(SEED)
bootstrap_means = bootstrap_rng.choice(values, size=(2000, n), replace=True).mean(axis=1)
b_low, b_high = np.quantile(bootstrap_means, [0.025, 0.975])
interval_rows.append({"variable": column, "n": n, "mean": mean, "t_low": t_low, "t_high": t_high, "bootstrap_low": b_low, "bootstrap_high": b_high})
normality_table = pd.DataFrame(normality_rows).set_index("variable")
normality_table["p_holm"] = holm_adjust(normality_table["p_value"])
interval_table = pd.DataFrame(interval_rows).set_index("variable")
print("Ориентировочный normaltest (округление не учтено в его калибровке):")
print(normality_table.to_string(float_format=lambda value: f"{value:.5g}"))
print("Средние и 95% доверительные интервалы:")
print(interval_table.round(5).to_string())Ориентировочный normaltest (округление не учтено в его калибровке):
statistic p_value p_holm
variable
moisture 350.17 9.1492e-77 2.7448e-76
acidity 29.572 3.7886e-07 7.5773e-07
body 28.17 7.6388e-07 7.6388e-07
Средние и 95% доверительные интервалы:
n mean t_low t_high bootstrap_low bootstrap_high
variable
moisture 1058 0.11000 0.10862 0.11138 0.10864 0.11133
acidity 1058 7.52662 7.50767 7.54557 7.50814 7.54521
body 1058 7.50303 7.48580 7.52027 7.48606 7.52013
4. Три случайные величины
4.1 Влажность зёрен
Описание
В качестве первой величины выбрали влажность зёрен. Она записана в долях: например, 0,11 соответствует 11%. На её значение влияют условия сушки и хранения, особенности пробы и процедура измерения (то есть факторов много и просчитать их невозможно)
Разведочный анализ и распределение
</> показать код
#@title Разведочный анализ и распределение
x = data["moisture"].to_numpy()
q1, q3 = np.quantile(x, [0.25, 0.75])
iqr = q3 - q1
flagged = (x < q1 - 1.5 * iqr) | (x > q3 + 1.5 * iqr)
summary = pd.Series({"n": len(x), "mean": x.mean(), "median": np.median(x), "variance_ddof1": x.var(ddof=1), "std_ddof1": x.std(ddof=1), "min": x.min(), "q1": q1, "q3": q3, "max": x.max(), "skewness": stats.skew(x, bias=False), "IQR_flags": flagged.sum()})
print(summary.round(5).to_string())
fig, axes = plt.subplots(2, 2, figsize=(14, 8))
axes[0, 0].hist(x, bins=np.arange(x.min()-.005, x.max()+.015, .01), density=True, color=PALETTE["accent"], alpha=0.75)
grid = np.linspace(x.min(), x.max(), 300)
axes[0, 0].plot(grid, stats.norm.pdf(grid, x.mean(), x.std(ddof=1)), color=PALETTE["main"], label="Нормальное приближение")
axes[0, 0].set(xlabel=labels["moisture"], ylabel="Плотность", title="Гистограмма")
axes[0, 0].legend()
axes[0, 1].step(np.sort(x), np.arange(1, len(x)+1) / len(x), where="post", color=PALETTE["main"])
axes[0, 1].set(xlabel=labels["moisture"], ylabel="F_n(x)", title="Эмпирическая функция распределения", ylim=(0, 1.02))
axes[1, 0].boxplot(x, vert=False, medianprops={"color": PALETTE["accent"], "linewidth": 2}, flierprops={"markeredgecolor": PALETTE["ink"], "markersize": 4})
axes[1, 0].set(xlabel=labels["moisture"], title="Ящик с усами (выбросы оставлены)", yticks=[])
stats.probplot(x, dist="norm", plot=axes[1, 1])
axes[1, 1].get_lines()[0].set(color=PALETTE["accent"], markersize=3)
axes[1, 1].get_lines()[1].set(color=PALETTE["main"])
axes[1, 1].set(xlabel="Теоретические квантили", ylabel="Упорядоченные значения")
axes[1, 1].set_title("Q-Q график против нормального")
fig.suptitle(f"Влажность зёрен: Arabica, n={len(x)}")
fig.tight_layout()
plt.show()
show_md(
f"**Вывод по графикам влажности**\n\n"
f"На гистограмме видим узкий пик около 11%. Центральная половина наблюдений лежит между "
f"**{fmt(100*q1,1)} и {fmt(100*q3,1)}%**; на CDF этому участку соответствует резкий подъём. "
f"Крупные ступени показывают, что одни и те же записанные значения встречаются много раз\n\n"
f"Асимметрия равна **{fmt(stats.skew(x,bias=False),2)}**: распределение сильнее смещено в сторону малой влажности. "
f"При этом редкие большие значения тоже есть: весь диапазон составляет "
f"{fmt(100*x.min(),1)}–{fmt(100*x.max(),1)}%. Boxplot отмечает {int(flagged.sum())} наблюдения по правилу 1,5 IQR, "
f"но само это правило не устанавливает ошибку измерения, поэтому эти строки сохраняем\n\n"
"На Q–Q-графике видим выраженные отклонения от прямой на обоих концах и ступени в середине. "
"Нормальная кривая плохо передаёт одновременно узкий центральный пик и крайние значения"
)
n 1058.00000 mean 0.11000 median 0.11000 variance_ddof1 0.00052 std_ddof1 0.02279 min 0.01000 q1 0.10000 q3 0.12000 max 0.28000 skewness -1.10924 IQR_flags 74.00000
/var/folders/ks/2brr670j7579x50gvzcdjtg80000gp/T/ipykernel_8004/3214719103.py:17: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.
axes[1, 0].boxplot(x, vert=False, medianprops={"color": PALETTE["accent"], "linewidth": 2}, flierprops={"markeredgecolor": PALETTE["ink"], "markersize": 4})
Вывод по графикам влажности
На гистограмме видим узкий пик около 11%. Центральная половина наблюдений лежит между 10,0 и 12,0%; на CDF этому участку соответствует резкий подъём. Крупные ступени показывают, что одни и те же записанные значения встречаются много раз
Асимметрия равна -1,11: распределение сильнее смещено в сторону малой влажности. При этом редкие большие значения тоже есть: весь диапазон составляет 1,0–28,0%. Boxplot отмечает 74 наблюдения по правилу 1,5 IQR, но само это правило не устанавливает ошибку измерения, поэтому эти строки сохраняем
На Q–Q-графике видим выраженные отклонения от прямой на обоих концах и ступени в середине. Нормальная кривая плохо передаёт одновременно узкий центральный пик и крайние значения
Независимость и оценки
Рассчитываем среднее влажности и сравниваем два способа построения доверительного интервала: t-интервал и bootstrap
</> показать код
#@title Независимость и оценки
estimate = interval_table.loc["moisture"]
show_table(estimate.to_frame("estimate"))
show_md(f"Получили среднее **{fmt(estimate['mean'],5)}**. Приближённый 95% t-интервал: **[{fmt(estimate['t_low'],5)}; {fmt(estimate['t_high'],5)}]**. Bootstrap-интервал: **[{fmt(estimate['bootstrap_low'],5)}; {fmt(estimate['bootstrap_high'],5)}]**. Границы двух интервалов близки.")| estimate | |
|---|---|
| n | 1058.00000 |
| mean | 0.11000 |
| t_low | 0.10862 |
| t_high | 0.11138 |
| bootstrap_low | 0.10864 |
| bootstrap_high | 0.11133 |
Получили среднее 0,11000. Приближённый 95% t-интервал: [0,10862; 0,11138]. Bootstrap-интервал: [0,10864; 0,11133]. Границы двух интервалов близки.
4.2 Кислотность
Описание
Вторая величина - оценка кислотности кофе при дегустации. Нас интересуют её разброс, форма распределения и связь с остальными показателями
Разведочный анализ и распределение
</> показать код
#@title Разведочный анализ и распределение
x = data["acidity"].to_numpy()
q1, q3 = np.quantile(x, [0.25, 0.75])
iqr = q3 - q1
flagged = (x < q1 - 1.5 * iqr) | (x > q3 + 1.5 * iqr)
summary = pd.Series({"n": len(x), "mean": x.mean(), "median": np.median(x), "variance_ddof1": x.var(ddof=1), "std_ddof1": x.std(ddof=1), "min": x.min(), "q1": q1, "q3": q3, "max": x.max(), "skewness": stats.skew(x, bias=False), "IQR_flags": flagged.sum()})
print(summary.round(5).to_string())
fig, axes = plt.subplots(2, 2, figsize=(14, 8))
axes[0, 0].hist(x, bins="fd", density=True, color=PALETTE["accent"], alpha=0.75)
grid = np.linspace(x.min(), x.max(), 300)
axes[0, 0].plot(grid, stats.norm.pdf(grid, x.mean(), x.std(ddof=1)), color=PALETTE["main"], label="Нормальное приближение")
axes[0, 0].set(xlabel=labels["acidity"], ylabel="Плотность", title="Гистограмма")
axes[0, 0].legend()
axes[0, 1].step(np.sort(x), np.arange(1, len(x)+1) / len(x), where="post", color=PALETTE["main"])
axes[0, 1].set(xlabel=labels["acidity"], ylabel="F_n(x)", title="Эмпирическая функция распределения", ylim=(0, 1.02))
axes[1, 0].boxplot(x, vert=False, medianprops={"color": PALETTE["accent"], "linewidth": 2}, flierprops={"markeredgecolor": PALETTE["ink"], "markersize": 4})
axes[1, 0].set(xlabel=labels["acidity"], title="Ящик с усами (выбросы оставлены)", yticks=[])
stats.probplot(x, dist="norm", plot=axes[1, 1])
axes[1, 1].get_lines()[0].set(color=PALETTE["accent"], markersize=3)
axes[1, 1].get_lines()[1].set(color=PALETTE["main"])
axes[1, 1].set(xlabel="Теоретические квантили", ylabel="Упорядоченные значения")
axes[1, 1].set_title("Q-Q график против нормального")
fig.suptitle(f"Кислотность: Arabica, n={len(x)}")
fig.tight_layout()
plt.show()
show_md(
f"**Вывод по графикам кислотности**\n\n"
f"Оценки сосредоточены около 7,5 балла: среднее равно **{fmt(x.mean(),2)}**, "
f"медиана — **{fmt(np.median(x),2)}**. Центральная половина наблюдений лежит между "
f"{fmt(q1,2)} и {fmt(q3,2)} балла. На CDF наиболее быстрый рост видим в этой области, "
f"а ступени соответствуют повторяющимся оценкам\n\n"
f"Асимметрия небольшая: **{fmt(stats.skew(x,bias=False),2)}**. Гистограмма в целом похожа на колокол, "
f"но boxplot отмечает {int(flagged.sum())} крайних наблюдений с обеих сторон. "
"На Q–Q-графике центральные точки близки к прямой, а на концах отклоняются от неё. "
"Поэтому внешнее сходство с нормальной кривой ещё не означает согласия всего распределения с этой моделью"
)
n 1058.00000 mean 7.52662 median 7.50000 variance_ddof1 0.09867 std_ddof1 0.31411 min 6.08000 q1 7.33000 q3 7.75000 max 8.75000 skewness 0.11533 IQR_flags 19.00000
/var/folders/ks/2brr670j7579x50gvzcdjtg80000gp/T/ipykernel_8004/1732386877.py:17: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.
axes[1, 0].boxplot(x, vert=False, medianprops={"color": PALETTE["accent"], "linewidth": 2}, flierprops={"markeredgecolor": PALETTE["ink"], "markersize": 4})
Вывод по графикам кислотности
Оценки сосредоточены около 7,5 балла: среднее равно 7,53, медиана — 7,50. Центральная половина наблюдений лежит между 7,33 и 7,75 балла. На CDF наиболее быстрый рост видим в этой области, а ступени соответствуют повторяющимся оценкам
Асимметрия небольшая: 0,12. Гистограмма в целом похожа на колокол, но boxplot отмечает 19 крайних наблюдений с обеих сторон. На Q–Q-графике центральные точки близки к прямой, а на концах отклоняются от неё. Поэтому внешнее сходство с нормальной кривой ещё не означает согласия всего распределения с этой моделью
Независимость и оценки
Для кислотности также оцениваем среднее и строим t-интервал и bootstrap-интервал. Далее проверяем её связь с полнотой напитка
</> показать код
#@title Независимость и оценки
estimate = interval_table.loc["acidity"]
show_table(estimate.to_frame("estimate"))
show_md(f"Получили среднее **{fmt(estimate['mean'],5)}**. Приближённый 95% t-интервал: **[{fmt(estimate['t_low'],5)}; {fmt(estimate['t_high'],5)}]**. Bootstrap-интервал: **[{fmt(estimate['bootstrap_low'],5)}; {fmt(estimate['bootstrap_high'],5)}]**. Границы двух интервалов близки.")| estimate | |
|---|---|
| n | 1058.00000 |
| mean | 7.52662 |
| t_low | 7.50767 |
| t_high | 7.54557 |
| bootstrap_low | 7.50814 |
| bootstrap_high | 7.54521 |
Получили среднее 7,52662. Приближённый 95% t-интервал: [7,50767; 7,54557]. Bootstrap-интервал: [7,50814; 7,54521]. Границы двух интервалов близки.
4.3 Полнота напитка
Описание
Третья величина - оценка полноты напитка, то есть его текстуры и ощущения во рту. Рассматриваем, как эта оценка меняется между образцами и насколько согласуется с кислотностью
Разведочный анализ и распределение
</> показать код
#@title Разведочный анализ и распределение
x = data["body"].to_numpy()
q1, q3 = np.quantile(x, [0.25, 0.75])
iqr = q3 - q1
flagged = (x < q1 - 1.5 * iqr) | (x > q3 + 1.5 * iqr)
summary = pd.Series({"n": len(x), "mean": x.mean(), "median": np.median(x), "variance_ddof1": x.var(ddof=1), "std_ddof1": x.std(ddof=1), "min": x.min(), "q1": q1, "q3": q3, "max": x.max(), "skewness": stats.skew(x, bias=False), "IQR_flags": flagged.sum()})
print(summary.round(5).to_string())
fig, axes = plt.subplots(2, 2, figsize=(14, 8))
axes[0, 0].hist(x, bins="fd", density=True, color=PALETTE["accent"], alpha=0.75)
grid = np.linspace(x.min(), x.max(), 300)
axes[0, 0].plot(grid, stats.norm.pdf(grid, x.mean(), x.std(ddof=1)), color=PALETTE["main"], label="Нормальное приближение")
axes[0, 0].set(xlabel=labels["body"], ylabel="Плотность", title="Гистограмма")
axes[0, 0].legend()
axes[0, 1].step(np.sort(x), np.arange(1, len(x)+1) / len(x), where="post", color=PALETTE["main"])
axes[0, 1].set(xlabel=labels["body"], ylabel="F_n(x)", title="Эмпирическая функция распределения", ylim=(0, 1.02))
axes[1, 0].boxplot(x, vert=False, medianprops={"color": PALETTE["accent"], "linewidth": 2}, flierprops={"markeredgecolor": PALETTE["ink"], "markersize": 4})
axes[1, 0].set(xlabel=labels["body"], title="Ящик с усами (выбросы оставлены)", yticks=[])
stats.probplot(x, dist="norm", plot=axes[1, 1])
axes[1, 1].get_lines()[0].set(color=PALETTE["accent"], markersize=3)
axes[1, 1].get_lines()[1].set(color=PALETTE["main"])
axes[1, 1].set(xlabel="Теоретические квантили", ylabel="Упорядоченные значения")
axes[1, 1].set_title("Q-Q график против нормального")
fig.suptitle(f"Полнота напитка: Arabica, n={len(x)}")
fig.tight_layout()
plt.show()
show_md(
f"**Вывод по графикам полноты**\n\n"
f"Среднее и медиана близки: **{fmt(x.mean(),2)}** и **{fmt(np.median(x),2)}** балла. "
f"Центральная половина наблюдений лежит между {fmt(q1,2)} и {fmt(q3,2)} балла. "
f"Разброс меньше, чем у кислотности: стандартные отклонения равны "
f"{fmt(x.std(ddof=1),3)} и {fmt(data['acidity'].std(ddof=1),3)} балла соответственно\n\n"
f"Распределение почти симметрично: асимметрия равна **{fmt(stats.skew(x,bias=False),2)}**. "
"На гистограмме и CDF видим заметные скопления на отдельных оценках. "
f"Boxplot отмечает {int(flagged.sum())} крайних наблюдения, а концы Q–Q-графика расходятся с прямой. "
"Нормальная кривая передаёт общий вид середины, но хуже описывает крайние значения и частоты отдельных баллов"
)
n 1058.00000 mean 7.50303 median 7.50000 variance_ddof1 0.08159 std_ddof1 0.28564 min 6.33000 q1 7.33000 q3 7.67000 max 8.58000 skewness 0.05974 IQR_flags 23.00000
/var/folders/ks/2brr670j7579x50gvzcdjtg80000gp/T/ipykernel_8004/1376181146.py:17: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.
axes[1, 0].boxplot(x, vert=False, medianprops={"color": PALETTE["accent"], "linewidth": 2}, flierprops={"markeredgecolor": PALETTE["ink"], "markersize": 4})
Вывод по графикам полноты
Среднее и медиана близки: 7,50 и 7,50 балла. Центральная половина наблюдений лежит между 7,33 и 7,67 балла. Разброс меньше, чем у кислотности: стандартные отклонения равны 0,286 и 0,314 балла соответственно
Распределение почти симметрично: асимметрия равна 0,06. На гистограмме и CDF видим заметные скопления на отдельных оценках. Boxplot отмечает 23 крайних наблюдения, а концы Q–Q-графика расходятся с прямой. Нормальная кривая передаёт общий вид середины, но хуже описывает крайние значения и частоты отдельных баллов
Независимость и оценки
Рассчитываем среднее полноты напитка и доверительные интервалы теми же методами, что и для двух предыдущих величин
</> показать код
#@title Независимость и оценки
estimate = interval_table.loc["body"]
show_table(estimate.to_frame("estimate"))
show_md(f"Получили среднее **{fmt(estimate['mean'],5)}**. Приближённый 95% t-интервал: **[{fmt(estimate['t_low'],5)}; {fmt(estimate['t_high'],5)}]**. Bootstrap-интервал: **[{fmt(estimate['bootstrap_low'],5)}; {fmt(estimate['bootstrap_high'],5)}]**. Границы двух интервалов близки.")| estimate | |
|---|---|
| n | 1058.00000 |
| mean | 7.50303 |
| t_low | 7.48580 |
| t_high | 7.52027 |
| bootstrap_low | 7.48606 |
| bootstrap_high | 7.52013 |
Получили среднее 7,50303. Приближённый 95% t-интервал: [7,48580; 7,52027]. Bootstrap-интервал: [7,48606; 7,52013]. Границы двух интервалов близки.
4.4 Нормализация и стандартизация
Чтобы сравнить показатели в общей шкале, используем два преобразования:
- min–max: $u_i=(x_i-x_{min})/(x_{max}-x_{min})$;
- z-score: $z_i=(x_i-\bar x)/s$, где стандартное отклонение рассчитано с
ddof=1.
После min–max значения лежат в [0;1], а после стандартизации среднее равно 0 и стандартное отклонение - 1. Сохраняем исходную таблицу и записываем преобразованные значения отдельно. Эти преобразования меняют шкалу, сохраняя форму распределения. Для min–max особенно важны крайние значения, так как они задают границы диапазона
</> показать код
#@title 4.4 Нормализация и стандартизация
source_features = data[columns].copy()
scaling_parameters = pd.DataFrame({
"min": source_features.min(),
"max": source_features.max(),
"mean": source_features.mean(),
"std_ddof1": source_features.std(ddof=1),
})
scaling_parameters["range"] = scaling_parameters["max"] - scaling_parameters["min"]
assert scaling_parameters["range"].gt(0).all()
assert scaling_parameters["std_ddof1"].gt(0).all()
data_minmax = (source_features - scaling_parameters["min"]) / scaling_parameters["range"]
data_zscore = (source_features - scaling_parameters["mean"]) / scaling_parameters["std_ddof1"]
scaling_checks = pd.DataFrame({
"minmax_min": data_minmax.min(),
"minmax_max": data_minmax.max(),
"z_mean": data_zscore.mean(),
"z_std_ddof1": data_zscore.std(ddof=1),
"skew_original": source_features.skew(),
"skew_minmax": data_minmax.skew(),
"skew_zscore": data_zscore.skew(),
})
print("Параметры преобразований (исходные шкалы):")
print(scaling_parameters.round(6).to_string())
print("\nПроверка диапазона, стандартизации и сохранения асимметрии:")
print(scaling_checks.round(6).to_string())
assert np.allclose(data_minmax.min(), 0)
assert np.allclose(data_minmax.max(), 1)
assert np.allclose(data_zscore.mean(), 0, atol=1e-12)
assert np.allclose(data_zscore.std(ddof=1), 1)
assert np.allclose(source_features.skew(), data_zscore.skew())
assert np.allclose(source_features.corr(), data_minmax.corr())
assert np.allclose(source_features.corr(), data_zscore.corr())
assert data[columns].equals(source_features)
fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))
for axis, transformed, title, ylabel in [
(axes[0], data_minmax, "Min-max нормализация", "Значение в [0, 1]"),
(axes[1], data_zscore, "Z-стандартизация (ddof=1)", "Стандартизованное значение"),
]:
axis.boxplot([transformed[column].to_numpy() for column in columns])
axis.set_xticks(np.arange(1, len(columns) + 1))
axis.set_xticklabels(columns)
axis.set(title=title, ylabel=ylabel)
axes[1].axhline(0, color=PALETTE["main"], linestyle="--", linewidth=1)
fig.suptitle(f"Сравнение масштабирования: Arabica, n={len(data)}, выбросы по IQR оставлены")
fig.tight_layout()
plt.show()
show_md(
"**Вывод по графикам преобразований**\n\n"
"После min–max все показатели лежат в диапазоне от 0 до 1. При этом коробка влажности выглядит узкой: "
"её границы растягивают редкие крайние значения, а основная масса остаётся около 10–12%\n\n"
"После z-score средние равны 0, а стандартные отклонения - 1, поэтому сравниваем положение наблюдений "
"относительно среднего каждого показателя. Крайние значения и различия формы сохранились: "
"преобразование шкалы не сделало распределения нормальными и не устранило зависимость показателей. "
"Для объяснения свойств кофе дальше используем исходные единицы"
)Параметры преобразований (исходные шкалы):
min max mean std_ddof1 range
moisture 0.01 0.28 0.110000 0.022794 0.27
acidity 6.08 8.75 7.526616 0.314114 2.67
body 6.33 8.58 7.503034 0.285637 2.25
Проверка диапазона, стандартизации и сохранения асимметрии:
minmax_min minmax_max z_mean z_std_ddof1 skew_original skew_minmax skew_zscore
moisture 0.0 1.0 -0.0 1.0 -1.109243 -1.109243 -1.109243
acidity 0.0 1.0 0.0 1.0 0.115326 0.115326 0.115326
body 0.0 1.0 -0.0 1.0 0.059739 0.059739 0.059739
Вывод по графикам преобразований
После min–max все показатели лежат в диапазоне от 0 до 1. При этом коробка влажности выглядит узкой: её границы растягивают редкие крайние значения, а основная масса остаётся около 10–12%
После z-score средние равны 0, а стандартные отклонения - 1, поэтому сравниваем положение наблюдений относительно среднего каждого показателя. Крайние значения и различия формы сохранились: преобразование шкалы не сделало распределения нормальными и не устранило зависимость показателей. Для объяснения свойств кофе дальше используем исходные единицы
5. Математическая база распределений (критерий 6)
5.1 Какое распределение описываем
Для начала описываем каждую величину с помощью эмпирического распределения, используя частоты значений в подготовленной таблице
Пусть $N$ - размер подготовленного набора, $I$ равновероятно выбирает номер строки: $P(I=i)=1/N$, а $X=x_I$ - значение одного показателя в этой строке
Уникальные значения обозначаем $a_1<\dots<a_K$, их количества - $c_1,\dots,c_K$. Тогда функция вероятностей (PMF) имеет вид
Выбираем строки с одинаковой вероятностью, поэтому вероятность отдельного значения зависит от числа его повторений
Эмпирическая функция распределения (CDF)
Здесь $\mathbf{1}\{x_i\le t\}$ равна единице, если $x_i\le t$, и нулю в остальных случаях
Функция $F_N(t)$ не убывает, непрерывна справа и имеет скачок величины $p_j$ в точке $a_j$. Ниже минимума $F_N(t)=0$, начиная с максимума $F_N(t)=1$
Например, вероятность попадания в интервал рассчитываем так
В нашей таблице конечное число значений, поэтому эмпирическое распределение дискретно. Частоты описывают записанные значения напрямую. По их повторениям нельзя установить, получены ли они простым округлением или более сложной обработкой оценок
5.2 Параметры и форма
По этим вероятностям рассчитываем математическое ожидание, дисперсию и центральные моменты
Коэффициент асимметрии
Эксцесс
Для дисперсии эмпирического распределения делим сумму квадратов отклонений на $N$. Для несмещённой выборочной оценки дисперсии при независимых одинаково распределённых наблюдениях используем $N-1$
Асимметрию и эксцесс здесь считаем непосредственно по моментам распределения
Мода - любое значение с наибольшим $p_j$; мод может быть несколько
Квантиль уровня $q\in(0,1)$ определяется как
В таблицах EDA используем линейную интерполяцию pandas/NumPy для вычисления квартилей
По знаку асимметрии оцениваем направление перекоса распределения, а по эксцессу сравниваем четвёртый стандартизованный момент с нормальным законом. При выводах также учитываем графики и проверку согласия
5.3 Нормальный закон как модель для сравнения
Для $Y\sim\mathcal{N}(\mu,\sigma^2)$, где $\sigma>0$, плотность имеет вид
Функция распределения
Здесь $\Phi$ - функция распределения стандартного нормального закона
У нормального закона $\mathbb{E}(Y)=\mu$, $\operatorname{Var}(Y)=\sigma^2$. Он симметричен и одномодален, среднее, медиана и мода совпадают, асимметрия и эксцесс равны нулю. Область возможных значений — вся числовая прямая
$f_Y(y)$ - плотность, а не вероятность отдельного значения: $P(Y=y)=0$, вероятность интервала равна интегралу плотности
У дискретной модели выше вероятность наблюдаемого значения положительна, поэтому высоты PMF и непрерывной плотности не сравниваем как одинаковые величины
На гистограммах с нормировкой плотности добавляем нормальную кривую с $\mu=\bar{x}$ и $\sigma=s$ (ddof=1). На Q–Q-графике смотрим, насколько точки отклоняются от прямой
Далее подбираем параметры нормального и логистического распределений и проверяем их согласование с данными с учётом округления
</> показать код
#@title 5.3 Нормальный закон как модель для сравнения
distribution_tables = {}
distribution_math_rows = []
fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
for axis, column in zip(axes, columns):
counts = data[column].value_counts().sort_index()
support = counts.index.to_numpy(dtype=float)
probabilities = counts.to_numpy() / len(data)
distribution_table = pd.DataFrame({
"value": support, "count": counts.to_numpy(),
"probability": probabilities, "CDF": probabilities.cumsum(),
})
distribution_tables[column] = distribution_table
mean = np.sum(support * probabilities)
variance = np.sum((support - mean)**2 * probabilities)
skewness = np.sum((support - mean)**3 * probabilities) / variance**1.5
excess = np.sum((support - mean)**4 * probabilities) / variance**2 - 3
modes = support[counts.to_numpy() == counts.max()]
distribution_math_rows.append({
"variable": column, "N": len(data), "unique_values": len(support),
"support_min": support.min(), "support_max": support.max(),
"modes": ", ".join(f"{value:g}" for value in modes),
"mode_probability": probabilities.max(),
"mean_emp": mean, "variance_emp_ddof0": variance,
"skewness_emp": skewness, "excess_emp": excess,
})
assert counts.sum() == len(data)
assert np.isclose(probabilities.sum(), 1)
assert np.all(np.diff(distribution_table["CDF"]) >= 0)
assert np.isclose(distribution_table["CDF"].iloc[-1], 1)
assert np.isclose(mean, data[column].mean())
assert np.isclose(variance, data[column].var(ddof=0))
axis.vlines(support, 0, probabilities, color=PALETTE["main"], alpha=.8)
axis.scatter(support, probabilities, color=PALETTE["accent"], s=18)
axis.set(title=f"{NAMES[column]}: функция вероятностей", xlabel="Значение (исходная шкала)", ylabel="Вероятность")
print(f"\n{column}: пять наиболее частых значений")
print(distribution_table.sort_values(["count", "value"], ascending=[False, True]).head(5).round(6).to_string(index=False))
fig.suptitle(f"Равновероятный выбор строки из {len(data)} записей Arabica")
fig.tight_layout()
plt.show()
distribution_math_table = pd.DataFrame(distribution_math_rows).set_index("variable")
print("\nМатематические характеристики эмпирических распределений:")
print(distribution_math_table.round(6).to_string())
show_md(
f"**Вывод по графикам вероятностей**\n\n"
f"Самый заметный пик у влажности: значение 0,11 встречается в "
f"**{fmt(100*distribution_math_table.loc['moisture','mode_probability'],1)}%** строк. "
f"Для кислотности и полноты наиболее частая оценка - 7,5 балла, её доли равны "
f"{fmt(100*distribution_math_table.loc['acidity','mode_probability'],1)}% и "
f"{fmt(100*distribution_math_table.loc['body','mode_probability'],1)}%. "
"Высота каждой линии показывает вероятность записанного значения при равновероятном выборе строки\n\n"
f"У кислотности и полноты асимметрия близка к нулю, но эмпирический эксцесс положителен: "
f"{fmt(distribution_math_table.loc['acidity','excess_emp'],2)} и "
f"{fmt(distribution_math_table.loc['body','excess_emp'],2)}. "
f"У влажности он заметно больше: {fmt(distribution_math_table.loc['moisture','excess_emp'],2)}. "
"Это согласуется с заметной ролью крайних значений на предыдущих графиках\n\n"
)moisture: пять наиболее частых значений value count probability CDF 0.11 381 0.360113 0.617202 0.12 283 0.267486 0.884688 0.10 180 0.170132 0.257089 0.13 75 0.070888 0.955577 0.09 26 0.024575 0.086957 acidity: пять наиболее частых значений value count probability CDF 7.50 134 0.126654 0.531191 7.58 118 0.111531 0.642722 7.67 110 0.103970 0.747637 7.42 108 0.102079 0.404537 7.33 94 0.088847 0.302457 body: пять наиболее частых значений value count probability CDF 7.50 162 0.153119 0.573724 7.67 124 0.117202 0.794896 7.33 116 0.109641 0.316635 7.42 110 0.103970 0.420605 7.58 109 0.103025 0.676749
Математические характеристики эмпирических распределений:
N unique_values support_min support_max modes mode_probability mean_emp variance_emp_ddof0 skewness_emp excess_emp
variable
moisture 1058 22 0.01 0.28 0.11 0.360113 0.110000 0.000519 -1.107670 10.500643
acidity 1058 29 6.08 8.75 7.5 0.126654 7.526616 0.098574 0.115162 1.166210
body 1058 28 6.33 8.58 7.5 0.153119 7.503034 0.081511 0.059654 1.176584
Вывод по графикам вероятностей
Самый заметный пик у влажности: значение 0,11 встречается в 36,0% строк. Для кислотности и полноты наиболее частая оценка - 7,5 балла, её доли равны 12,7% и 15,3%. Высота каждой линии показывает вероятность записанного значения при равновероятном выборе строки
У кислотности и полноты асимметрия близка к нулю, но эмпирический эксцесс положителен: 1,17 и 1,18. У влажности он заметно больше: 10,50. Это согласуется с заметной ролью крайних значений на предыдущих графиках
5.4 Подбор теоретических моделей и проверка согласия
Для каждого показателя сравниваем два семейства: нормальное и логистическое
Плотность логистического распределения:
Функция распределения:
Логистическое распределение симметрично, его параметры:
Выбрали логистическое распределение как симметричную альтернативу нормальному с большим четвёртым стандартизованным моментом. Оба семейства имеют неограниченную область возможных значений, поэтому рассматриваем их как приближения
Допущение о записи: для проверки задаём модель $X=Q_\delta(Y)$, где $Y$ имеет выбранное непрерывное распределение, а $Q_\delta$ округляет его до сетки с шагом $\delta=0{,}01$
Этот шаг соответствует разряду сотых в CSV, но не устанавливает точность измерения или фактический шаг выставления дегустационных баллов. Записанные оценки могли пройти дополнительное усреднение и обработку, порядок которых по этой таблице не восстанавливаем
Поэтому простое округление принимаем как наше приближение. В этой проверке оцениваем сочетание «распределение + способ записи», а не отдельно закон скрытой физической величины
Тогда вероятность записанного значения:
Здесь $G_\theta$ - функция распределения выбранного семейства, а $\theta$ — набор его параметров
Логарифмическое правдоподобие:
Параметры подбираем по максимуму этого правдоподобия. Для сравнения моделей рассчитываем информационный критерий Акаике:
Меньший AIC указывает на предпочтительную модель среди сравниваемых, а согласие с данными проверяем отдельно
Для каждой пары «показатель - семейство» формулируем гипотезы:
При предположении независимости и одинакового распределения строк проверяем:
- $H_0$: распределение записанных значений получается из выбранного семейства с неизвестными параметрами простым округлением до $0{,}01$
- $H_1$: распределение записанных значений не описывается этим сочетанием семейства и округления
Статистика $D$ — максимальное абсолютное расхождение эмпирической и модельной функций распределения, включая левые пределы скачков
Для получения $p$-значения используем параметрический бутстрэп:
- Подгоняем модель по исходным данным и рассчитываем наблюдаемую статистику $D_{\mathrm{obs}}$
- Генерируем из подогнанной модели выборку того же объёма
- Округляем полученные значения и заново оцениваем параметры
- Рассчитываем статистику $D^*$ для сгенерированной выборки и заново подогнанной модели
- Повторяем процедуру 499 раз
Оценка $p$-значения:
Для шести проверок применяем поправку Холма при $\alpha=0{,}05$
Это приближённая калибровка составной гипотезы с оценёнными параметрами. Минимальное возможное значение $p_{\mathrm{MC}}$ при таком числе повторений — $0{,}002$
При отклонении $H_0$ делаем вывод о несогласии именно с заданной моделью. Причиной могут быть форма распределения, неучтённая обработка оценок или нарушение предположения о независимости строк — эта проверка не разделяет их влияние
Для дальнейших экспериментов используем эмпирическое распределение, которое сохраняет наблюдаемые значения и их частоты без восстановления скрытого механизма получения баллов
</> показать код
#@title 5.4 Подбор теоретических моделей и проверка согласия
"""Проверяем приближение: непрерывное распределение и простое округление до 0,01."""
import numpy as np
from scipy import stats, optimize
def rounded_fit(x, family, step=0.01):
"""Оцениваем параметры распределения по значениям, записанным на сетке."""
x = np.asarray(x, dtype=float)
support, counts = np.unique(x, return_counts=True)
center, unit = x.mean(), x.std(ddof=0)
dist = getattr(stats, family)
loc0, scale0 = dist.fit(x)
def negative_log_likelihood(theta):
loc = center + unit * theta[0]
scale = unit * np.exp(theta[1])
lower = (support - step / 2 - loc) / scale
upper = (support + step / 2 - loc) / scale
probability = np.where(lower >= 0,
dist.sf(lower) - dist.sf(upper),
dist.cdf(upper) - dist.cdf(lower))
return -np.dot(counts, np.log(np.maximum(probability, np.finfo(float).tiny)))
start = [(loc0-center)/unit, np.log(scale0/unit)]
result = optimize.minimize(negative_log_likelihood, start, method='L-BFGS-B',
bounds=[(-10,10),(-6,4)], options={'ftol':1e-10})
if not result.success:
result = optimize.minimize(negative_log_likelihood, start, method='Powell',
bounds=[(-10,10),(-6,4)])
if not result.success or not np.isfinite(result.fun):
raise RuntimeError('Distribution fit did not converge: ' + result.message)
loc = center + unit * result.x[0]
scale = unit * np.exp(result.x[1])
return float(loc), float(scale), float(-result.fun)
def rounded_cdf_distance(x, family, loc, scale, step=0.01):
support, counts = np.unique(x, return_counts=True)
right = np.cumsum(counts) / len(x)
left = right - counts / len(x)
dist = getattr(stats, family)
model_right = dist.cdf((support + step/2 - loc)/scale)
model_left = dist.cdf((support - step/2 - loc)/scale)
return max(np.max(np.abs(right-model_right)), np.max(np.abs(left-model_left)))
fitted_model_rows = []
fitted_parameters = {}
model_rng = np.random.default_rng(SEED + 610)
model_repetitions = 499
for column in columns:
values = data[column].to_numpy()
for family in ["norm", "logistic"]:
loc, scale, log_likelihood = rounded_fit(values, family)
observed_D = rounded_cdf_distance(values, family, loc, scale)
extreme = 0
for _ in range(model_repetitions):
latent = (model_rng.normal(loc, scale, len(values)) if family == "norm" else
model_rng.logistic(loc, scale, len(values)))
simulated = np.round(latent, 2)
sim_loc, sim_scale, _ = rounded_fit(simulated, family)
extreme += rounded_cdf_distance(simulated, family, sim_loc, sim_scale) >= observed_D - 1e-12
p_mc = (extreme+1)/(model_repetitions+1)
lower, upper = (0,1) if column == "moisture" else (0,10)
dist = getattr(stats, family)
fitted_parameters[(column,family)] = (loc,scale)
fitted_model_rows.append({"variable":column, "family":family, "loc":loc, "scale":scale,
"log_likelihood":log_likelihood, "AIC":4-2*log_likelihood,
"CDF_distance":observed_D, "p_MC":p_mc,
"latent_mass_outside_physical_scale":dist.cdf((lower-loc)/scale)+dist.sf((upper-loc)/scale)})
fitted_model_table = pd.DataFrame(fitted_model_rows)
fitted_model_table["p_Holm_6"] = holm_adjust(fitted_model_table["p_MC"])
fitted_model_table["reject_model"] = fitted_model_table["p_Holm_6"] < ALPHA
show_table(fitted_model_table.set_index(["variable","family"]), 6)
fig, axes = plt.subplots(1,3,figsize=(14, 4.5))
for axis, column in zip(axes, columns):
values = data[column].to_numpy()
grid = np.round(np.arange(values.min()-.01, values.max()+.015, .01),2)
axis.step(grid, np.searchsorted(np.sort(values),grid,side="right")/len(values), where="post", color=PALETTE["ink"],label="Эмпирическая ФР")
for family in ["norm","logistic"]:
loc,scale = fitted_parameters[(column,family)]
axis.step(grid,getattr(stats,family).cdf((grid+.005-loc)/scale),where="post",label={"norm": "нормальное", "logistic": "логистическое"}.get(family, family))
axis.set(title=NAMES[column],xlabel=labels[column],ylabel="F(x)")
axis.legend()
fig.tight_layout()
plt.show()
rejected_count = int(fitted_model_table["reject_model"].sum())
show_md(
"**Вывод по сравнению моделей**\n\n"
"Для влажности модельные CDF заметно расходятся с эмпирической около центрального скачка. "
"Для кислотности и полноты общий ход кривых похож, но отдельные скачки наблюдаемых частот модели передают хуже. "
"Внешней близости кривых недостаточно, чтобы принять модель\n\n"
"В таблице логистическая модель имеет меньший AIC для каждого показателя, однако это только выбор "
"между двумя предложенными приближениями. "
f"После поправки Холма отвергаем **{rejected_count} из {len(fitted_model_table)}** сочетаний "
"семейства и простого округления до 0,01\n\n"
"Не делаем из этого вывод, что скрытые величины заведомо не могут иметь нормальное или логистическое распределение "
"при другом механизме получения записанных оценок. Для экспериментов с повторными выборками "
"оставляем эмпирическое распределение"
)| loc | scale | log_likelihood | AIC | CDF_distance | p_MC | latent_mass_outside_physical_scale | p_Holm_6 | reject_model | ||
|---|---|---|---|---|---|---|---|---|---|---|
| variable | family | |||||||||
| moisture | norm | 0.109999 | 0.022612 | -2372.590488 | 4749.180977 | 0.166607 | 0.002 | 0.000001 | 0.012 | True |
| logistic | 0.111631 | 0.009450 | -2159.066346 | 4322.132693 | 0.080188 | 0.002 | 0.000007 | 0.012 | True | |
| acidity | norm | 7.526616 | 0.313952 | -5147.842654 | 10299.685308 | 0.068959 | 0.002 | 0.000000 | 0.012 | True |
| logistic | 7.522624 | 0.172937 | -5131.980256 | 10267.960513 | 0.056646 | 0.002 | 0.000001 | 0.012 | True | |
| body | norm | 7.503034 | 0.285487 | -5047.296855 | 10098.593709 | 0.070977 | 0.002 | 0.000000 | 0.012 | True |
| logistic | 7.501539 | 0.157535 | -5032.176101 | 10068.352202 | 0.069020 | 0.002 | 0.000000 | 0.012 | True |
Вывод по сравнению моделей
Для влажности модельные CDF заметно расходятся с эмпирической около центрального скачка. Для кислотности и полноты общий ход кривых похож, но отдельные скачки наблюдаемых частот модели передают хуже. Внешней близости кривых недостаточно, чтобы принять модель
В таблице логистическая модель имеет меньший AIC для каждого показателя, однако это только выбор между двумя предложенными приближениями. После поправки Холма отвергаем 6 из 6 сочетаний семейства и простого округления до 0,01
Не делаем из этого вывод, что скрытые величины заведомо не могут иметь нормальное или логистическое распределение при другом механизме получения записанных оценок. Для экспериментов с повторными выборками оставляем эмпирическое распределение
6. Связи между показателями
Строим диаграммы рассеяния и рассчитываем коэффициенты Пирсона и Спирмена для трёх пар показателей. Первый характеризует линейную связь, второй - монотонную. Для трёх тестов каждого типа применяем поправку Холма
</> показать код
#@title 6. Связи между показателями
pairs = [("moisture", "acidity"), ("moisture", "body"), ("acidity", "body")]
correlation_rows = []
fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
for axis, (first, second) in zip(axes, pairs):
pearson = stats.pearsonr(data[first], data[second])
spearman = stats.spearmanr(data[first], data[second])
correlation_rows.append({"pair": first + " / " + second, "pearson_r": pearson.statistic, "pearson_p": pearson.pvalue, "spearman_rho": spearman.statistic, "spearman_p": spearman.pvalue})
axis.scatter(data[first], data[second], s=16, alpha=0.22, color=PALETTE["accent"])
axis.set(xlabel=labels[first], ylabel=labels[second], title=f"ρ Спирмена = {spearman.statistic:.3f}")
fig.tight_layout()
plt.show()
correlation_table = pd.DataFrame(correlation_rows).set_index("pair")
correlation_table["pearson_p_holm"] = holm_adjust(correlation_table["pearson_p"])
correlation_table["spearman_p_holm"] = holm_adjust(correlation_table["spearman_p"])
print(correlation_table.to_string(float_format=lambda value: f"{value:.5g}"))
show_md(
"**Вывод по диаграммам рассеяния**\n\n"
f"Самая заметная связь - между кислотностью и полнотой: облако точек вытянуто вверх, "
f"коэффициент Спирмена равен **{fmt(correlation_table.loc['acidity / body','spearman_rho'],3)}**. "
"Образцы с большей оценкой кислотности обычно получают и большую оценку полноты, "
"но разброс точек показывает, что соответствие не однозначное\n\n"
f"Связи влажности с кислотностью и полнотой слабее и направлены вниз: "
f"{fmt(correlation_table.loc['moisture / acidity','spearman_rho'],3)} и "
f"{fmt(correlation_table.loc['moisture / body','spearman_rho'],3)}. "
"При большом числе наблюдений даже слабая связь может быть статистически значимой, "
"поэтому учитываем величину коэффициента. Скопления точек на сетке связаны с повторяющимися записанными значениями; "
)
pearson_r pearson_p spearman_rho spearman_p pearson_p_holm spearman_p_holm pair moisture / acidity -0.1816 2.6889e-09 -0.18017 3.5944e-09 2.6889e-09 4.7173e-09 moisture / body -0.18872 6.1319e-10 -0.18225 2.3587e-09 1.2264e-09 4.7173e-09 acidity / body 0.65442 2.3604e-130 0.62357 4.8388e-115 7.0812e-130 1.4516e-114
Вывод по диаграммам рассеяния
Самая заметная связь - между кислотностью и полнотой: облако точек вытянуто вверх, коэффициент Спирмена равен 0,624. Образцы с большей оценкой кислотности обычно получают и большую оценку полноты, но разброс точек показывает, что соответствие не однозначное
Связи влажности с кислотностью и полнотой слабее и направлены вниз: -0,180 и -0,182. При большом числе наблюдений даже слабая связь может быть статистически значимой, поэтому учитываем величину коэффициента. Скопления точек на сетке связаны с повторяющимися записанными значениями;
6.1 Зависимость и проверка гипотезы (критерии 2 и 7)
Проверяем связь кислотности и полноты: оба показателя получены при оценке одного образца кофе
Обозначаем кислотность через $A$, полноту через $B$ и формулируем гипотезы:
Уровень значимости: $\alpha=0{,}05$
При независимости для любых множеств значений $U$ и $V$ выполняется:
Сначала сравниваем обе части равенства для событий $A\ge7{,}5$ и $B\ge7{,}5$ в эмпирическом распределении:
Затем проводим перестановочный тест со статистикой $|\rho_s|$, где $\rho_s$ — коэффициент ранговой корреляции Спирмена
Оставляем кислотность на месте, случайно переставляем оценки полноты между строками и заново рассчитываем корреляцию. При $H_0$ и обменности строк такие перестановки дают распределение статистики при независимости
Совпадающим значениям присваиваем усреднённые ранги. Всего выполняем 9999 перестановок и рассчитываем:
Здесь $\rho_s^{\mathrm{obs}}$ — корреляция в исходных данных, а $\rho_s^{(b)}$ — корреляция после перестановки с номером $b$
Если $p_{\mathrm{MC}}<0{,}05$, отвергаем $H_0$
Дополнительно показываем поправку Бонферрони на три пары показателей:
Нулевая корреляция сама по себе не доказывает независимость, поскольку коэффициент Спирмена прежде всего выявляет монотонную связь. Поэтому отсутствие значимого результата означало бы, что этим тестом зависимость не обнаружена
Описание метода:
</> показать код
#@title 6.1 Зависимость и проверка гипотезы (критерии 2 и 7)
# пары оценок одного образца
criterion2_a = data["acidity"].to_numpy()
criterion2_b = data["body"].to_numpy()
criterion2_n = len(criterion2_a)
criterion2_event_a = criterion2_a >= 7.5
criterion2_event_b = criterion2_b >= 7.5
criterion2_pa = criterion2_event_a.mean()
criterion2_pb = criterion2_event_b.mean()
criterion2_pab = (criterion2_event_a & criterion2_event_b).mean()
print("Иллюстрация для эмпирического распределения:")
print(f"P(acidity >= 7.5) = {criterion2_pa:.6f}")
print(f"P(body >= 7.5) = {criterion2_pb:.6f}")
print(f"P(оба события) = {criterion2_pab:.6f}")
print(f"Произведение вероятностей = {criterion2_pa * criterion2_pb:.6f}")
# коэффициент Спирмена
criterion2_rank_a = stats.rankdata(criterion2_a, method="average")
criterion2_rank_b = stats.rankdata(criterion2_b, method="average")
criterion2_rank_a = criterion2_rank_a - criterion2_rank_a.mean()
criterion2_rank_b = criterion2_rank_b - criterion2_rank_b.mean()
criterion2_scale = np.linalg.norm(criterion2_rank_a) * np.linalg.norm(criterion2_rank_b)
criterion2_rho = np.dot(criterion2_rank_a, criterion2_rank_b) / criterion2_scale
criterion2_rng = np.random.default_rng(42)
criterion2_repetitions = 9999
criterion2_extreme = 0
for _ in range(criterion2_repetitions):
criterion2_permuted = criterion2_rng.permutation(criterion2_rank_b)
criterion2_rho_null = np.dot(criterion2_rank_a, criterion2_permuted) / criterion2_scale
if abs(criterion2_rho_null) >= abs(criterion2_rho) - 1e-12:
criterion2_extreme += 1
criterion2_p_mc = (criterion2_extreme + 1) / (criterion2_repetitions + 1)
criterion2_p_bonf = min(1.0, 3 * criterion2_p_mc)
print("\nПерестановочная проверка независимости:")
print(f"n = {criterion2_n}; Spearman rho = {criterion2_rho:.6f}")
print(f"Перестановок = {criterion2_repetitions}; не менее экстремальных = {criterion2_extreme}")
print(f"Monte Carlo p-value = {criterion2_p_mc:.6f}")
print(f"С поправкой Бонферрони на три пары = {criterion2_p_bonf:.6f}")
print("H0 независимости", "отвергается" if criterion2_p_bonf < 0.05 else "не отвергается", "при alpha = 0.05")
show_md(f"При α={ALPHA} **{'отвергаем' if criterion2_p_bonf<ALPHA else 'не отвергаем'} H0**. Получили ρs={fmt(criterion2_rho,4)}, p_MC={fmt(criterion2_p_mc,4)}; с поправкой на три пары - {fmt(criterion2_p_bonf,4)}. Вывод делаем при предположении обменности строк. Разрешение p_MC при 9999 перестановках составляет 0,0001")Иллюстрация для эмпирического распределения: P(acidity >= 7.5) = 0.595463 P(body >= 7.5) = 0.579395 P(оба события) = 0.443289 Произведение вероятностей = 0.345008 Перестановочная проверка независимости: n = 1058; Spearman rho = 0.623569 Перестановок = 9999; не менее экстремальных = 0 Monte Carlo p-value = 0.000100 С поправкой Бонферрони на три пары = 0.000300 H0 независимости отвергается при alpha = 0.05
При α=0.05 отвергаем H0. Получили ρs=0,6236, p_MC=0,0001; с поправкой на три пары - 0,0003. Вывод делаем при предположении обменности строк. Разрешение p_MC при 9999 перестановках составляет 0,0001
6.2 Проверка по странам и фермам
Проверяем, сколько строк относится к одним странам, фермам и организациям. Также ищем совпадения полного сочетания «страна - ферма - номер партии - дата оценки». Такие записи рассматриваем как кандидатов для проверки и автоматически не удаляем
Чтобы оценить устойчивость связи кислотности и полноты, используем два подхода:
- Рассчитываем корреляции внутри стран с n≥20. Затем центрируем ранги внутри каждой страны и переставляем значения только между строками одной страны. Так проверяем связь после учёта межстрановых различий. Строки без страны в эту проверку не включаем
- Проводим кластерный bootstrap по фермам. Для группировки используем страну и приведённое к одному виду название фермы. Пустые и сборные названия вроде
variousисключаем из этой проверки. На каждом повторении выбираем с возвращением K ферм и включаем все их строки. Всего делаем 1000 повторений и сравниваем результат с обычным bootstrap на том же поднаборе
Для перестановок внутри страны предполагаем обменность строк, а для кластерного bootstrap - независимость между фермами. При этом внутри фермы записи могут быть зависимыми. Неполные сведения об экспертах и партиях остаются ограничением этого анализа
</> показать код
#@title 6.2 Проверка по странам и фермам
def normalize_label(series):
return series.astype("string").str.strip().str.lower().str.replace(r"\s+", " ", regex=True)
group_audit_rows = []
for field in ["country_of_origin","farm_name","owner","certification_body"]:
labels_clean = normalize_label(data[field]).dropna()
counts = labels_clean.value_counts()
group_audit_rows.append({"field":field, "missing":int(data[field].isna().sum()),
"distinct_labels":len(counts), "labels_with_multiple_rows":int(counts.gt(1).sum()),
"largest_group":int(counts.max())})
group_audit = pd.DataFrame(group_audit_rows).set_index("field")
show_table(group_audit,0)
identity_fields = ["country_of_origin","farm_name","lot_number","grading_date"]
identity_complete = data.dropna(subset=identity_fields)
duplicate_identity_n = int(identity_complete.duplicated(identity_fields, keep=False).sum())
print("Строк с полным потенциальным ключом партии:",len(identity_complete),
"; строк с совпадающим ключом:",duplicate_identity_n,"(кандидаты для проверки, не автоматические дубликаты)")
country_data = data.dropna(subset=["country_of_origin"]).reset_index(drop=True)
country_groups = list(country_data.groupby("country_of_origin",sort=True).indices.values())
within_country_rows = []
for country, subset in country_data.groupby("country_of_origin",sort=True):
if len(subset)>=20:
within_country_rows.append({"country":country,"n":len(subset),
"rho":stats.spearmanr(subset["acidity"],subset["body"]).statistic})
within_country_table = pd.DataFrame(within_country_rows).set_index("country")
show_table(within_country_table.sort_values("n",ascending=False),4)
rank_a = stats.rankdata(country_data["acidity"].to_numpy()).astype(float)
rank_b = stats.rankdata(country_data["body"].to_numpy()).astype(float)
for positions in country_groups:
rank_a[positions] -= rank_a[positions].mean()
rank_b[positions] -= rank_b[positions].mean()
within_scale = np.linalg.norm(rank_a)*np.linalg.norm(rank_b)
within_rho = np.dot(rank_a,rank_b)/within_scale
within_rng = np.random.default_rng(SEED+270)
within_extreme = 0
within_repetitions = 1999
for _ in range(within_repetitions):
permuted_b = rank_b.copy()
for positions in country_groups:
permuted_b[positions] = within_rng.permutation(rank_b[positions])
within_extreme += abs(np.dot(rank_a,permuted_b)/within_scale) >= abs(within_rho)-1e-12
within_p = (within_extreme+1)/(within_repetitions+1)
farm_label = normalize_label(data["farm_name"])
country_label = normalize_label(data["country_of_origin"])
generic_pattern = r"^(?:various|varios|varias|several|multiple|mixed|unknown|none|n/?a|not specified|\d+)(?:$|\s)"
farm_usable = farm_label.notna() & country_label.notna() & farm_label.ne("") & ~farm_label.str.contains(generic_pattern,regex=True,na=False)
farm_data = data.loc[farm_usable].copy().reset_index(drop=True)
farm_data["farm_cluster"] = (country_label[farm_usable]+" | "+farm_label[farm_usable]).to_numpy()
farm_groups = list(farm_data.groupby("farm_cluster",sort=True).indices.values())
farm_values = farm_data[columns].to_numpy()
farm_rho = stats.spearmanr(farm_values[:,1],farm_values[:,2]).statistic
bootstrap_rng = np.random.default_rng(SEED+271)
farm_boot = np.empty((1000,4))
iid_farm_boot = np.empty((1000,4))
for repetition in range(1000):
chosen = bootstrap_rng.integers(len(farm_groups),size=len(farm_groups))
cluster_positions = np.concatenate([farm_groups[j] for j in chosen])
iid_positions = bootstrap_rng.integers(len(farm_values),size=len(farm_values))
for destination, positions in [(farm_boot,cluster_positions),(iid_farm_boot,iid_positions)]:
values = farm_values[positions]
destination[repetition,:3] = values.mean(axis=0)
destination[repetition,3] = stats.spearmanr(values[:,1],values[:,2]).statistic
farm_cluster_ci = np.quantile(farm_boot,[.025,.975],axis=0)
farm_iid_ci = np.quantile(iid_farm_boot,[.025,.975],axis=0)
farm_bootstrap_table = pd.DataFrame({
"point_estimate":np.r_[farm_values.mean(axis=0),farm_rho],
"iid_low":farm_iid_ci[0],"iid_high":farm_iid_ci[1],
"farm_cluster_low":farm_cluster_ci[0],"farm_cluster_high":farm_cluster_ci[1],
},index=columns+["acidity_body_rho"])
show_table(farm_bootstrap_table,5)
show_md(f"После центрирования рангов внутри стран получили статистику {fmt(within_rho,3)} и p_MC={fmt(within_p,4)} при n={len(country_data)}. В проверку по фермам вошли {len(farm_data)} строк и {len(farm_groups)} кластеров. Для этого поднабора получили ρs={fmt(farm_rho,3)} и 95% кластерный bootstrap-интервал [{fmt(farm_cluster_ci[0,3],3)}; {fmt(farm_cluster_ci[1,3],3)}]. Положительная связь сохраняется при обоих способах учёта группирования")| missing | distinct_labels | labels_with_multiple_rows | largest_group | |
|---|---|---|---|---|
| field | ||||
| country_of_origin | 1 | 33 | 25 | 222 |
| farm_name | 239 | 494 | 112 | 39 |
| owner | 6 | 278 | 121 | 155 |
| certification_body | 0 | 24 | 23 | 201 |
Строк с полным потенциальным ключом партии: 185 ; строк с совпадающим ключом: 6 (кандидаты для проверки, не автоматические дубликаты)
| n | rho | |
|---|---|---|
| country | ||
| Mexico | 222 | 0.5153 |
| Guatemala | 174 | 0.6505 |
| Colombia | 106 | 0.5235 |
| Brazil | 101 | 0.5205 |
| Taiwan | 57 | 0.7884 |
| Honduras | 52 | 0.6813 |
| United States (Hawaii) | 46 | 0.2858 |
| Costa Rica | 40 | 0.5353 |
| Ethiopia | 39 | 0.6930 |
| Tanzania, United Republic Of | 39 | 0.5914 |
| Nicaragua | 26 | 0.5756 |
| Thailand | 26 | 0.2685 |
| Uganda | 25 | 0.4835 |
| point_estimate | iid_low | iid_high | farm_cluster_low | farm_cluster_high | |
|---|---|---|---|---|---|
| moisture | 0.11153 | 0.11008 | 0.11307 | 0.10974 | 0.11314 |
| acidity | 7.52918 | 7.50891 | 7.55136 | 7.50088 | 7.55679 |
| body | 7.48884 | 7.47054 | 7.50922 | 7.46369 | 7.51496 |
| acidity_body_rho | 0.65332 | 0.60532 | 0.69493 | 0.60493 | 0.69921 |
После центрирования рангов внутри стран получили статистику 0,570 и p_MC=0,0005 при n=1057. В проверку по фермам вошли 753 строк и 489 кластеров. Для этого поднабора получили ρs=0,653 и 95% кластерный bootstrap-интервал [0,605; 0,699]. Положительная связь сохраняется при обоих способах учёта группирования
7. Эмпирическая состоятельность оценок (критерий 3)
Оценка $\widehat{\theta}_n$ состоятельна, если для любого $\varepsilon>0$:
Проверяем выборочное среднее $\bar{X}_n$ как оценку математического ожидания и исправленную выборочную дисперсию $S_n^2$ как оценку дисперсии для каждого из трёх показателей
Хотим проверить, уменьшается ли ошибка этих оценок при увеличении объёма выборки
7.1 Модель эксперимента
1058 подготовленных строк задают эмпирическое распределение: при каждом извлечении независимо выбираем строку с возвращением, вероятность выбора каждой строки равна $1/1058$
Известные параметры этой модели — среднее полного набора $\mu_{\mathrm{emp}}$ и его дисперсия $\sigma_{\mathrm{emp}}^2$ с делителем 1058 (ddof=0)
В повторных выборках оцениваем дисперсию с делителем $n-1$ (ddof=1). Выборки с возвращением могут содержать больше 1058 наблюдений, но не добавляют новых реальных данных
Для каждого объёма:
получаем 1000 повторных выборок
Для обеих оценок рассчитываем:
- Смещение - среднюю ошибку оценки по повторениям
- RMSE - корень из средней квадратичной ошибки по повторениям
- Долю повторений, в которых абсолютная ошибка превышает заданный порог
- Нормированный RMSE для сопоставления разных шкал
Обозначим число повторений через $R=1000$, а оценку в повторении $b$ — через $\widehat{\theta}_n^{(b)}$
Смещение оцениваем так:
RMSE рассчитываем так:
Долю превышений порога рассчитываем так:
Пороги фиксированы при всех $n$:
Это позволяет проследить, как часто оценка заметно отклоняется от известного параметра при увеличении объёма выборки
Для нормированного RMSE ошибку среднего делим на $\sigma_{\mathrm{emp}}$, а ошибку оценки дисперсии - на $\sigma_{\mathrm{emp}}^2$
7.2 Теоретическая опора
Для независимых одинаково распределённых наблюдений с конечной дисперсией выборочное среднее состоятельно по закону больших чисел
У выбранного эмпирического распределения существуют все моменты. Для среднего:
Для исправленной выборочной дисперсии при $n>1$:
Здесь $\mu_{4,\mathrm{emp}}$ — четвёртый центральный момент эмпирического распределения:
Обе оценки в этой модели несмещённые, поэтому их MSE равен их дисперсии
По неравенству Чебышёва:
Пунктир на графиках показывает теоретический RMSE этой модели
В эксперименте задаём независимость повторным отбором с возвращением и исследуем поведение оценок для эмпирического распределения
</> показать код
#@title 7.2 Теоретическая опора
sizes = np.array([20, 50, 100, 250, 500, 1000, 5000])
repetitions = 1000
simulation_rng = np.random.default_rng(SEED)
simulation_rows = []
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
for column_index, column in enumerate(columns):
values = data[column].to_numpy()
mean_target = values.mean()
variance_target = values.var(ddof=0)
sigma = np.sqrt(variance_target)
fourth_moment = np.mean((values - mean_target)**4)
for size in sizes:
samples = simulation_rng.choice(values, size=(repetitions, size), replace=True)
estimates_by_kind = {
"mean": samples.mean(axis=1),
"variance": samples.var(axis=1, ddof=1),
}
for estimator, estimates in estimates_by_kind.items():
target = mean_target if estimator == "mean" else variance_target
scale = sigma if estimator == "mean" else variance_target
epsilon = 0.25 * scale
errors = estimates - target
theory_mse = (variance_target / size if estimator == "mean" else
(fourth_moment - (size - 3) / (size - 1) * variance_target**2) / size)
simulation_rows.append({
"variable": column, "estimator": estimator, "n": size,
"target": target, "bias": errors.mean(),
"RMSE": np.sqrt(np.mean(errors**2)),
"normalized_RMSE": np.sqrt(np.mean(errors**2)) / scale,
"theory_normalized_RMSE": np.sqrt(theory_mse) / scale,
"epsilon": epsilon,
"P_error_gt_epsilon": np.mean(np.abs(errors) > epsilon),
})
variable_results = pd.DataFrame(simulation_rows)
for row_index, estimator in enumerate(["mean", "variance"]):
subset = variable_results.loc[
variable_results["variable"].eq(column) & variable_results["estimator"].eq(estimator)
]
axis = axes[row_index, column_index]
axis.loglog(subset["n"], subset["normalized_RMSE"], "o-", label="Моделирование")
axis.loglog(subset["n"], subset["theory_normalized_RMSE"], "--", label="Теория (н.о.р.)")
axis.set(xlabel="Объём выборки n", ylabel="Нормированная RMSE", title=f"{NAMES[column]}: {ESTIMATOR_RU.get(estimator, estimator)}")
axis.legend()
fig.tight_layout()
plt.show()
simulation_table = pd.DataFrame(simulation_rows)
print(f"Повторений для каждого объёма: {repetitions}")
print("Средние: нормировка на sigma_emp; дисперсии: на variance_emp.")
print(simulation_table.drop(columns=["theory_normalized_RMSE"]).round(6).to_string(index=False))
summary_rows = []
for column in columns:
for estimator in ["mean","variance"]:
subset = simulation_table.loc[simulation_table["variable"].eq(column)&simulation_table["estimator"].eq(estimator)]
first,last = subset.iloc[0],subset.iloc[-1]
summary_rows.append({"variable":column,"estimator":estimator,
"RMSE_small_n":first["RMSE"],"RMSE_large_n":last["RMSE"],
"P_large_error_small_n":first["P_error_gt_epsilon"],"P_large_error_large_n":last["P_error_gt_epsilon"]})
consistency_summary = pd.DataFrame(summary_rows).set_index(["variable","estimator"])
show_table(consistency_summary,6)
show_md(
"**Вывод по графикам состоятельности**\n\n"
"Во всех шести панелях ошибка уменьшается с ростом n, а результаты повторных выборок близки "
"к теоретическим пунктирным кривым. Для среднего скорость снижения RMSE соответствует зависимости "
"от обратного квадратного корня из n\n\n"
"Для дисперсии влажности нормированная ошибка больше, чем для дисперсий дегустационных баллов. "
"Это согласуется с большим четвёртым стандартизованным моментом влажности: её оценка дисперсии "
"сильнее зависит от попадания крайних наблюдений в выборку\n\n"
"Полученные кривые иллюстрируют состоятельность среднего и исправленной дисперсии в выбранной iid-модели. "
"Выборки до 5000 наблюдений получаем с возвращением из исходного набора. "
"Нулевая наблюдаемая частота больших ошибок означает, что в 1000 повторениях такие ошибки не встретились"
)Повторений для каждого объёма: 1000
Средние: нормировка на sigma_emp; дисперсии: на variance_emp.
variable estimator n target bias RMSE normalized_RMSE epsilon P_error_gt_epsilon
moisture mean 20 0.110000 0.000081 0.004860 0.213297 0.005696 0.208
moisture variance 20 0.000519 -0.000025 0.000389 0.749543 0.000130 0.775
moisture mean 50 0.110000 -0.000063 0.003120 0.136949 0.005696 0.073
moisture variance 50 0.000519 0.000000 0.000250 0.480974 0.000130 0.607
moisture mean 100 0.110000 -0.000033 0.002304 0.101105 0.005696 0.020
moisture variance 100 0.000519 0.000006 0.000185 0.357135 0.000130 0.476
moisture mean 250 0.110000 0.000001 0.001474 0.064698 0.005696 0.000
moisture variance 250 0.000519 0.000005 0.000116 0.222711 0.000130 0.263
moisture mean 500 0.110000 0.000038 0.001017 0.044643 0.005696 0.000
moisture variance 500 0.000519 -0.000001 0.000080 0.154380 0.000130 0.100
moisture mean 1000 0.110000 -0.000017 0.000718 0.031514 0.005696 0.000
moisture variance 1000 0.000519 0.000001 0.000058 0.112520 0.000130 0.022
moisture mean 5000 0.110000 0.000009 0.000318 0.013938 0.005696 0.000
moisture variance 5000 0.000519 0.000001 0.000026 0.049837 0.000130 0.000
acidity mean 20 7.526616 0.001371 0.069362 0.220923 0.078491 0.243
acidity variance 20 0.098574 -0.000551 0.039479 0.400503 0.024644 0.518
acidity mean 50 7.526616 -0.000525 0.042237 0.134527 0.078491 0.064
acidity variance 50 0.098574 -0.000150 0.025678 0.260490 0.024644 0.318
acidity mean 100 7.526616 -0.000087 0.031191 0.099346 0.078491 0.013
acidity variance 100 0.098574 0.000807 0.017484 0.177371 0.024644 0.162
acidity mean 250 7.526616 0.000805 0.019746 0.062893 0.078491 0.000
acidity variance 250 0.098574 0.000512 0.010790 0.109463 0.024644 0.022
acidity mean 500 7.526616 -0.000385 0.014059 0.044779 0.078491 0.000
acidity variance 500 0.098574 0.000145 0.007717 0.078286 0.024644 0.003
acidity mean 1000 7.526616 -0.000506 0.010045 0.031993 0.078491 0.000
acidity variance 1000 0.098574 -0.000372 0.005574 0.056545 0.024644 0.000
acidity mean 5000 7.526616 0.000070 0.004356 0.013874 0.078491 0.000
acidity variance 5000 0.098574 0.000101 0.002526 0.025629 0.024644 0.000
body mean 20 7.503034 0.000542 0.063040 0.220805 0.071375 0.244
body variance 20 0.081511 -0.000522 0.033825 0.414974 0.020378 0.550
body mean 50 7.503034 -0.000296 0.041359 0.144864 0.071375 0.078
body variance 50 0.081511 0.000541 0.021082 0.258633 0.020378 0.315
body mean 100 7.503034 -0.000468 0.027306 0.095643 0.071375 0.003
body variance 100 0.081511 -0.000303 0.014353 0.176088 0.020378 0.151
body mean 250 7.503034 -0.000952 0.018529 0.064900 0.071375 0.000
body variance 250 0.081511 0.000122 0.009371 0.114966 0.020378 0.026
body mean 500 7.503034 0.000304 0.012617 0.044191 0.071375 0.000
body variance 500 0.081511 -0.000019 0.006433 0.078917 0.020378 0.002
body mean 1000 7.503034 0.000176 0.008931 0.031280 0.071375 0.000
body variance 1000 0.081511 -0.000181 0.004668 0.057266 0.020378 0.000
body mean 5000 7.503034 0.000016 0.003925 0.013746 0.071375 0.000
body variance 5000 0.081511 -0.000000 0.002063 0.025305 0.020378 0.000
| RMSE_small_n | RMSE_large_n | P_large_error_small_n | P_large_error_large_n | ||
|---|---|---|---|---|---|
| variable | estimator | ||||
| moisture | mean | 0.004860 | 0.000318 | 0.208 | 0.0 |
| variance | 0.000389 | 0.000026 | 0.775 | 0.0 | |
| acidity | mean | 0.069362 | 0.004356 | 0.243 | 0.0 |
| variance | 0.039479 | 0.002526 | 0.518 | 0.0 | |
| body | mean | 0.063040 | 0.003925 | 0.244 | 0.0 |
| variance | 0.033825 | 0.002063 | 0.550 | 0.0 |
Вывод по графикам состоятельности
Во всех шести панелях ошибка уменьшается с ростом n, а результаты повторных выборок близки к теоретическим пунктирным кривым. Для среднего скорость снижения RMSE соответствует зависимости от обратного квадратного корня из n
Для дисперсии влажности нормированная ошибка больше, чем для дисперсий дегустационных баллов. Это согласуется с большим четвёртым стандартизованным моментом влажности: её оценка дисперсии сильнее зависит от попадания крайних наблюдений в выборку
Полученные кривые иллюстрируют состоятельность среднего и исправленной дисперсии в выбранной iid-модели. Выборки до 5000 наблюдений получаем с возвращением из исходного набора. Нулевая наблюдаемая частота больших ошибок означает, что в 1000 повторениях такие ошибки не встретились
8. Генеральная совокупность и два способа отбора (критерий 5)
В этом разделе принимаем 1058 подготовленных записей Arabica за конечную генеральную совокупность. Из неё берём меньшие выборки и сравниваем их статистики с известными параметрами всей таблицы
Рассчитываем объём по выбранной точности и используем одинаковое $n=300$ для двух способов отбора:
- С возвращением: при каждом из 300 извлечений любая строка имеет вероятность $1/N$, одна строка может попасть несколько раз. Повторно выбранные строки сохраняем
- Без возвращения: выбираем 300 разных строк, каждое подмножество такого размера равновероятно, вероятность включения каждой строки - $n/N$. Извлечения зависимы из-за запрета повторов
Отбираем целые строки, сохраняя сочетания moisture, acidity и body
8.1 Сравнение статистик и погрешности
Рассчитываем среднее, медиану, дисперсию, стандартное отклонение, квартили и диапазон для совокупности и обеих выборок. Для среднего и дисперсии показываем ошибки относительно известных параметров
Среднее конечной совокупности:
Различаем две записи дисперсии конечной совокупности:
При отборе с возвращением выборочная дисперсия $s^2$ (ddof=1) несмещённо оценивает $\sigma_N^2$
Без возвращения она несмещённо оценивает $S_N^2$, поэтому для сопоставления с одним параметром $\sigma_N^2$ используем:
В таблице приводим и обычное $s^2$, и приведённую к общей цели оценку
Обозначаем отбор с возвращением через WR, без возвращения — через WOR
Точная стандартная ошибка среднего при известной совокупности:
Для построения интервалов используем выборочные оценки стандартной ошибки:
Строим приближённые 95% доверительные интервалы:
Проверяем, попало ли в них известное $\mu_N$. Используем нормальное приближение для распределения среднего и строим отдельный интервал для каждого показателя
Дополнительно повторяем обе схемы по 1000 раз при том же рассчитанном $n$ и сравниваем:
- Смещение и RMSE выборочного среднего
- Теоретическую стандартную ошибку среднего
- RMSE оценки дисперсии относительно $\sigma_N^2$
- Долю интервалов, покрывших $\mu_N$
Так сравниваем точность двух способов отбора по повторным выборкам
8.2 Почему выбран такой размер
Ориентиры половины ширины 95% интервала:
- Для влажности - $0{,}003$ доли, то есть 0,3 %
- Для кислотности и полноты — $0{,}04$ балла для каждого показателя
Обозначаем требуемую половину ширины интервала через $d$. Поскольку вся таблица известна, в формулы можем подставить её дисперсии
Для отбора с возвращением:
Для отбора без возвращения:
Здесь $z_{0{,}975}\approx1{,}96$ - квантиль стандартного нормального распределения
Берём максимум требуемых объёмов по трём показателям для WR и округляем вверх до ближайшей сотни. Получаем $n=300$ и используем этот объём для обеих схем
Это расчёт по нормальному приближению, фактическое покрытие дополнительно проверяем моделированием.
</> показать код
#@title 8.2 Почему выбран такой размер
population = data[columns].copy()
population_n = len(population)
precision_targets = pd.Series({"moisture":.003,"acidity":.04,"body":.04})
planning_variance = population.var(ddof=0)
planning_z = stats.norm.ppf(.975)
planning_a = planning_z**2 * planning_variance / precision_targets**2
sample_size_plan = pd.DataFrame({"target_half_width":precision_targets,
"minimum_n_WR":np.ceil(planning_a).astype(int),
"minimum_n_WOR":np.ceil(population_n*planning_a/(population_n-1+planning_a)).astype(int)})
sample_n = int(np.ceil(sample_size_plan["minimum_n_WR"].max()/100)*100)
show_table(sample_size_plan,4)
assert 1 < sample_n < population_n
sampling_designs = [("with replacement", True), ("without replacement", False)]
sampling_rng = np.random.default_rng(SEED + 500)
sample_positions = {
label: sampling_rng.choice(population_n, size=sample_n, replace=replace)
for label, replace in sampling_designs
}
samples_by_design = {
label: population.iloc[positions].copy()
for label, positions in sample_positions.items()
}
sampling_design_table = pd.DataFrame([
{"design": label, "draws": sample_n, "unique_rows": len(np.unique(positions)),
"repeated_draws": sample_n - len(np.unique(positions))}
for label, positions in sample_positions.items()
]).set_index("design")
assert sampling_design_table.loc["without replacement", "unique_rows"] == sample_n
population_stats = pd.DataFrame({
"N": population_n, "mean": population.mean(), "median": population.median(),
"variance_N": population.var(ddof=0), "variance_N_minus_1": population.var(ddof=1),
"std_N": population.std(ddof=0), "q1": population.quantile(.25),
"q3": population.quantile(.75), "min": population.min(), "max": population.max(),
})
sampling_comparison_rows = []
for label, replace in sampling_designs:
sample = samples_by_design[label]
for column in columns:
values = sample[column].to_numpy()
target_mean = population_stats.loc[column, "mean"]
target_variance = population_stats.loc[column, "variance_N"]
s2 = values.var(ddof=1)
variance_estimate = s2 if replace else (population_n - 1) / population_n * s2
correction = 1 if replace else np.sqrt(1 - sample_n / population_n)
se_estimate = np.sqrt(s2 / sample_n) * correction
low, high = values.mean() + np.array([-1, 1]) * stats.norm.ppf(.975) * se_estimate
sampling_comparison_rows.append({
"design": label, "variable": column, "n": len(values),
"mean": values.mean(), "median": np.median(values), "s2_ddof1": s2,
"variance_estimate_for_N": variance_estimate, "std_ddof1": np.sqrt(s2),
"q1": np.quantile(values, .25), "q3": np.quantile(values, .75),
"min": values.min(), "max": values.max(),
"mean_error": values.mean() - target_mean,
"variance_error": variance_estimate - target_variance,
"SE_estimate": se_estimate, "CI95_low": low, "CI95_high": high,
"covers_population_mean": bool(low <= target_mean <= high),
})
sampling_comparison = pd.DataFrame(sampling_comparison_rows)
print(f"Конечная генеральная совокупность: N={population_n}; n={sample_n} ({sample_n/population_n:.1%} N)")
print("\nСхемы отбора:")
print(sampling_design_table.to_string())
print("\nИзвестные статистики генеральной совокупности:")
print(population_stats.round(6).to_string())
print("\nСтатистики отдельных выборок и ошибки относительно генеральной совокупности:")
show_table(sampling_comparison.set_index(["design","variable"])[["mean","median","s2_ddof1","variance_estimate_for_N","std_ddof1","q1","q3","min","max"]],6)
show_table(sampling_comparison.set_index(["design","variable"])[["mean_error","variance_error","SE_estimate","CI95_low","CI95_high","covers_population_mean"]],6)
# Сравниваем два способа отбора при одинаковом объёме выборки
sampling_repetitions = 1000
sampling_simulation_rng = np.random.default_rng(SEED + 501)
population_values = population.to_numpy()
population_mean = population_values.mean(axis=0)
population_variance = population_values.var(axis=0, ddof=0)
sampling_simulation_rows = []
sampling_mean_draws = {}
for label, replace in sampling_designs:
means = np.empty((sampling_repetitions, len(columns)))
variance_estimates = np.empty_like(means)
covers = np.empty_like(means, dtype=bool)
for repetition in range(sampling_repetitions):
positions = sampling_simulation_rng.choice(population_n, size=sample_n, replace=replace)
selected = population_values[positions]
means[repetition] = selected.mean(axis=0)
s2 = selected.var(axis=0, ddof=1)
variance_estimates[repetition] = s2 if replace else (population_n - 1)/population_n*s2
correction = 1 if replace else np.sqrt(1 - sample_n/population_n)
half_width = stats.norm.ppf(.975) * np.sqrt(s2/sample_n) * correction
covers[repetition] = np.abs(means[repetition] - population_mean) <= half_width
sampling_mean_draws[label] = means
exact_correction = 1 if replace else np.sqrt((population_n-sample_n)/(population_n-1))
exact_se = np.sqrt(population_variance/sample_n) * exact_correction
for j, column in enumerate(columns):
errors = means[:, j] - population_mean[j]
sampling_simulation_rows.append({
"design": label, "variable": column,
"mean_bias": errors.mean(), "mean_RMSE": np.sqrt(np.mean(errors**2)),
"mean_SE_theory": exact_se[j],
"variance_bias": (variance_estimates[:, j] - population_variance[j]).mean(),
"variance_RMSE": np.sqrt(np.mean((variance_estimates[:, j] - population_variance[j])**2)),
"CI95_coverage": covers[:, j].mean(),
})
sampling_simulation = pd.DataFrame(sampling_simulation_rows)
print(f"\nСравнение схем по {sampling_repetitions} повторениям (n={sample_n}):")
print(sampling_simulation.round(6).to_string(index=False))
sampling_se_ratio = np.sqrt((population_n - sample_n)/(population_n - 1))
print(f"\nТочное отношение SE без возвращения / SE с возвращением: {sampling_se_ratio:.4f}")
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
colors = {"with replacement": PALETTE["accent"], "without replacement": PALETTE["main"]}
for j, column in enumerate(columns):
values = population[column].to_numpy()
sorted_values = np.sort(values)
axes[0, j].step(sorted_values, np.arange(1, population_n+1)/population_n,
where="post", color=PALETTE["ink"], linewidth=2, label="Генеральная совокупность")
for label, _ in sampling_designs:
sorted_sample = np.sort(samples_by_design[label][column].to_numpy())
axes[0, j].step(sorted_sample, np.arange(1, sample_n+1)/sample_n,
where="post", color=colors[label], alpha=.8, label=SAMPLING_RU[label])
axes[0, j].set(title=NAMES[column] + ": эмпирическая ФР", xlabel=labels[column], ylabel="F(x)")
axes[0, j].legend()
standardized_errors = {
label: (sampling_mean_draws[label][:, j] - values.mean())/values.std(ddof=0)
for label, _ in sampling_designs
}
bins = np.histogram_bin_edges(np.concatenate(list(standardized_errors.values())), bins=25)
for label, errors in standardized_errors.items():
axes[1, j].hist(errors, bins=bins, density=True, alpha=.45, color=colors[label], label=SAMPLING_RU[label])
axes[1, j].axvline(0, color=PALETTE["ink"], linestyle="--")
axes[1, j].set(title=NAMES[column] + ": ошибка выборочного среднего", xlabel="(X̄ выборки − μ) / σ генеральной", ylabel="Плотность")
axes[1, j].legend()
fig.suptitle(f"Генеральная N={population_n}, выборка n={sample_n}, повторений {sampling_repetitions}")
fig.tight_layout()
plt.show()
show_md(f"По расчёту точности выбрали **n={sample_n} при N={population_n}**. Отношение стандартных ошибок без возвращения и с возвращением равно {fmt(sampling_se_ratio,3)}. Таким образом, отбор без возвращения уменьшает теоретическую стандартную ошибку на {fmt(100*(1-sampling_se_ratio),1)}%. В {sampling_repetitions} повторениях интервалы покрыли средние генеральной совокупности в {fmt(100*sampling_simulation['CI95_coverage'].min(),1)}–{fmt(100*sampling_simulation['CI95_coverage'].max(),1)}% случаев. Для оценки параметров этой конечной таблицы предпочитаем отбор без возвращения")
show_md(
"**Что показывают графики отбора**\n\n"
"В верхнем ряду CDF обеих отдельных выборок в целом повторяют форму распределения полной таблицы, "
"но на отдельных участках отличаются от неё. Это ожидаемый разброс при отборе части строк\n\n"
"В нижнем ряду ошибки средних по повторным выборкам сосредоточены около нуля. "
"У отбора без возвращения распределения ошибок уже, что согласуется с меньшими RMSE в таблице "
"и поправкой на конечную совокупность. Это преимущество по повторным отборам: "
"одна конкретная выборка без возвращения всё равно может дать большую ошибку, чем одна выборка с возвращением"
)
| target_half_width | minimum_n_WR | minimum_n_WOR | |
|---|---|---|---|
| moisture | 0.003 | 222 | 184 |
| acidity | 0.040 | 237 | 194 |
| body | 0.040 | 196 | 166 |
Конечная генеральная совокупность: N=1058; n=300 (28.4% N)
Схемы отбора:
draws unique_rows repeated_draws
design
with replacement 300 263 37
without replacement 300 300 0
Известные статистики генеральной совокупности:
N mean median variance_N variance_N_minus_1 std_N q1 q3 min max
moisture 1058 0.110000 0.11 0.000519 0.000520 0.022784 0.10 0.12 0.01 0.28
acidity 1058 7.526616 7.50 0.098574 0.098667 0.313965 7.33 7.75 6.08 8.75
body 1058 7.503034 7.50 0.081511 0.081588 0.285502 7.33 7.67 6.33 8.58
Статистики отдельных выборок и ошибки относительно генеральной совокупности:
| mean | median | s2_ddof1 | variance_estimate_for_N | std_ddof1 | q1 | q3 | min | max | ||
|---|---|---|---|---|---|---|---|---|---|---|
| design | variable | |||||||||
| with replacement | moisture | 0.109533 | 0.11 | 0.000556 | 0.000556 | 0.023586 | 0.10 | 0.12 | 0.01 | 0.22 |
| acidity | 7.518733 | 7.50 | 0.096912 | 0.096912 | 0.311307 | 7.33 | 7.67 | 6.67 | 8.50 | |
| body | 7.504067 | 7.50 | 0.076369 | 0.076369 | 0.276349 | 7.33 | 7.67 | 6.33 | 8.58 | |
| without replacement | moisture | 0.108800 | 0.11 | 0.000719 | 0.000718 | 0.026813 | 0.10 | 0.12 | 0.01 | 0.28 |
| acidity | 7.501467 | 7.50 | 0.090507 | 0.090421 | 0.300844 | 7.25 | 7.67 | 6.67 | 8.50 | |
| body | 7.505733 | 7.50 | 0.064518 | 0.064457 | 0.254003 | 7.33 | 7.67 | 6.92 | 8.58 |
| mean_error | variance_error | SE_estimate | CI95_low | CI95_high | covers_population_mean | ||
|---|---|---|---|---|---|---|---|
| design | variable | ||||||
| with replacement | moisture | -0.000467 | 0.000037 | 0.001362 | 0.106864 | 0.112202 | True |
| acidity | -0.007883 | -0.001662 | 0.017973 | 7.483506 | 7.553960 | True | |
| body | 0.001033 | -0.005143 | 0.015955 | 7.472795 | 7.535338 | True | |
| without replacement | moisture | -0.001200 | 0.000199 | 0.001310 | 0.106232 | 0.111368 | True |
| acidity | -0.025150 | -0.008153 | 0.014702 | 7.472652 | 7.530282 | True | |
| body | 0.002699 | -0.017055 | 0.012413 | 7.481405 | 7.530062 | True |
Сравнение схем по 1000 повторениям (n=300):
design variable mean_bias mean_RMSE mean_SE_theory variance_bias variance_RMSE CI95_coverage
with replacement moisture -0.000014 0.001323 0.001315 0.000003 0.000110 0.944
with replacement acidity -0.000736 0.018391 0.018127 -0.000070 0.009830 0.945
with replacement body -0.000217 0.016694 0.016483 -0.000009 0.008547 0.947
without replacement moisture -0.000002 0.001099 0.001114 -0.000000 0.000091 0.947
without replacement acidity -0.000427 0.015130 0.015350 0.000019 0.008323 0.958
without replacement body -0.000164 0.013827 0.013959 -0.000248 0.006864 0.959
Точное отношение SE без возвращения / SE с возвращением: 0.8468
По расчёту точности выбрали n=300 при N=1058. Отношение стандартных ошибок без возвращения и с возвращением равно 0,847. Таким образом, отбор без возвращения уменьшает теоретическую стандартную ошибку на 15,3%. В 1000 повторениях интервалы покрыли средние генеральной совокупности в 94,4–95,9% случаев. Для оценки параметров этой конечной таблицы предпочитаем отбор без возвращения
Что показывают графики отбора
В верхнем ряду CDF обеих отдельных выборок в целом повторяют форму распределения полной таблицы, но на отдельных участках отличаются от неё. Это ожидаемый разброс при отборе части строк
В нижнем ряду ошибки средних по повторным выборкам сосредоточены около нуля. У отбора без возвращения распределения ошибок уже, что согласуется с меньшими RMSE в таблице и поправкой на конечную совокупность. Это преимущество по повторным отборам: одна конкретная выборка без возвращения всё равно может дать большую ошибку, чем одна выборка с возвращением
9. Интерактивный генератор выборок (доп. задание)
Генератор берёт одну из трёх величин, объём выборки $n$ и способ отбора, вытягивает выборку из подготовленного набора Arabica ($N=1058$) и сразу сравнивает её с генеральной совокупностью: эмпирические функции распределения на одном графике, гистограмма выборки и таблица среднего и дисперсии.
В Colab двигаем ползунки. В статичном просмотре на GitHub ниже сохранён пример для $n=100$ без возвращения.
</> показать код
#@title 9. Интерактивный генератор выборок (доп. задание)
def generate_sample(column="acidity", n=100, replace=False, seed=SEED):
"""Выборка объёма n из генеральной совокупности data[column]."""
population = data[column].to_numpy()
if not replace and n > len(population):
raise ValueError(f"Без возвращения нельзя взять больше {len(population)} записей")
rng = np.random.default_rng(seed)
return rng.choice(population, size=n, replace=replace)
def show_generator(column="acidity", n=100, replace=False, seed=SEED):
population = data[column].to_numpy()
sample = generate_sample(column, n, replace, seed)
scheme = "с возвращением" if replace else "без возвращения"
fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))
grid = np.sort(population)
axes[0].step(grid, np.arange(1, len(grid)+1) / len(grid), where="post",
color=PALETTE["ink"], linewidth=2, label="Генеральная совокупность")
axes[0].step(np.sort(sample), np.arange(1, n+1) / n, where="post",
color=PALETTE["main"], label=f"Выборка, n={n}")
axes[0].set(title="Эмпирические функции распределения", xlabel=labels[column], ylabel="F(x)")
axes[0].legend()
bins = np.histogram_bin_edges(population, bins="auto")
axes[1].hist(population, bins=bins, density=True, alpha=.35, color=PALETTE["ink"], label="Генеральная совокупность")
axes[1].hist(sample, bins=bins, density=True, alpha=.7, color=PALETTE["accent"], label="Выборка")
axes[1].set(title="Гистограммы", xlabel=labels[column], ylabel="Плотность")
axes[1].legend()
fig.suptitle(f"{NAMES[column]}: выборка {scheme}, seed={seed}")
fig.tight_layout()
plt.show()
table = pd.DataFrame({
"генеральная": [population.mean(), population.var(ddof=0)],
"выборка": [sample.mean(), sample.var(ddof=1)],
}, index=["среднее", "дисперсия"])
table["ошибка"] = table["выборка"] - table["генеральная"]
show_table(table, 6)
try:
import ipywidgets as widgets
widgets.interact(
show_generator,
column=widgets.Dropdown(options=[(NAMES[c], c) for c in columns], value="acidity", description="Величина"),
n=widgets.IntSlider(value=100, min=10, max=1000, step=10, description="n"),
replace=widgets.Checkbox(value=False, description="с возвращением"),
seed=widgets.IntText(value=SEED, description="seed"),
)
except ImportError:
show_md("ipywidgets не установлен: показываем статичный пример")interactive(children=(Dropdown(description='Величина', index=1, options=(('Влажность', 'moisture'), ('Кислотно…</> показать код
#@title 9. Интерактивный генератор выборок (доп. задание)
# пример для статичного просмотра (GitHub)
show_generator("acidity", n=100, replace=False, seed=SEED)| генеральная | выборка | ошибка | |
|---|---|---|---|
| среднее | 7.526616 | 7.494300 | -0.032316 |
| дисперсия | 0.098574 | 0.092413 | -0.006162 |
10. Итоги и выводы
Основные результаты анализа.
</> показать код
#@title 10. Итоги и выводы
show_md(f"Для основного анализа оставили {len(data)} записей Arabica из {len(raw)} строк исходного файла. Средняя влажность составила **{fmt(100*data['moisture'].mean(),2)}%**, кислотность - **{fmt(data['acidity'].mean(),4)}** балла, полнота - **{fmt(data['body'].mean(),4)}** балла")
show_md(f"Выявили положительную связь кислотности и полноты: **ρs={fmt(criterion2_rho,3)}**, p_MC={fmt(criterion2_p_mc,4)}. Она также сохранилась при проверках по странам и фермам")
show_md(f"Проверили нормальную и логистическую модели с допущением о простом округлении до 0,01 для трёх показателей. После поправки Холма отвергнуто **{int(fitted_model_table['reject_model'].sum())} из {len(fitted_model_table)}** моделей. Это результат для выбранного способа описания записанных значений, фактический механизм усреднения оценок по CSV не установлен. В экспериментах с выборками использовали эмпирические распределения. Показали уменьшение ошибки оценок с ростом n и сравнили два способа отбора выборки объёмом {sample_n} из {population_n} записей")
show_md("Свои выводы отношу к подготовленному набору Arabica. На результаты могут влиять исключение нулевой влажности, состав оценённых образцов и общие условия дегустации. Для интервалов использовали указанные в работе предположения о независимости и группировании наблюдений")Для основного анализа оставили 1058 записей Arabica из 1339 строк исходного файла. Средняя влажность составила 11,00%, кислотность - 7,5266 балла, полнота - 7,5030 балла
Выявили положительную связь кислотности и полноты: ρs=0,624, p_MC=0,0001. Она также сохранилась при проверках по странам и фермам
Проверили нормальную и логистическую модели с допущением о простом округлении до 0,01 для трёх показателей. После поправки Холма отвергнуто 6 из 6 моделей. Это результат для выбранного способа описания записанных значений, фактический механизм усреднения оценок по CSV не установлен. В экспериментах с выборками использовали эмпирические распределения. Показали уменьшение ошибки оценок с ростом n и сравнили два способа отбора выборки объёмом 300 из 1058 записей
Свои выводы отношу к подготовленному набору Arabica. На результаты могут влиять исключение нулевой влажности, состав оценённых образцов и общие условия дегустации. Для интервалов использовали указанные в работе предположения о независимости и группировании наблюдений
10.1 Выводы
✏️ Черновик — переписать своими словами перед сдачей. Цифры ниже взяты из расчётов блокнота.
- Данные. Из 1339 строк для анализа оставили 1058 записей Arabica. 252 записи с нулевой влажностью убрали: ноль здесь скорее «не измеряли», чем настоящее значение. Без них средняя влажность 11,0 %, а связь кислотности и полноты почти не меняется (0,624 против 0,615).
- Средние известны точно. 95 % интервалы узкие: влажность 10,86–11,14 %, кислотность 7,51–7,55, полнота 7,49–7,52 балла. t-интервал и bootstrap дают почти одно и то же.
- Готовые законы не подходят. Ни нормальное, ни логистическое распределение не прошли проверку ни для одной величины (6 из 6 отвергнуты). Значения записаны дискретно — на графиках видны «ступеньки», а у влажности ещё и длинный левый хвост из редких очень сухих зёрен. Ни одна из двух моделей этого не передаёт, поэтому дальше работали с эмпирическим распределением.
- Кислотность и полнота связаны. ρ Спирмена = 0,62, p ≈ 0,0001: чем выше один балл, тем обычно выше другой. Связь держится и внутри стран, и по фермам (0,65, интервал 0,61–0,70), то есть это не эффект одной страны. Причинность отсюда не следует: оба балла выставляют на одной дегустации.
- Оценки состоятельны. С ростом n ошибка среднего и дисперсии падает как $1/\sqrt{n}$ и совпадает с теорией. Дисперсия влажности сходится медленнее остальных — из-за тех же крайних значений.
- С возвращением и без. При n=300 из 1058 оба способа дают близкие оценки. Без возвращения разброс среднего меньше, потому что выборка — заметная доля всей совокупности (28 %). Чтобы оценить среднее с заданной точностью, без возвращения хватает меньшего n (например, 194 против 237 для кислотности).
10.2 Как использовали нейросеть
✏️ Команде дописать. Что известно точно:
- Оформление второй версии (нумерация и якоря разделов, оглавление, скрытый код, единый стиль и русские подписи графиков), интерактивный генератор из раздела 9 и черновик выводов 10.1 подготовлены с помощью Claude (Anthropic) по запросу дизайнеров.
- Расчёты разделов 1–8 — работа инфохимиков.
- _Где проверяли и спорили с нейросетью, что исправили руками:_ …
10.3 Участники и cat_mark_table
</> показать код
#@title 10.3 Участники и cat_mark_table
team_table = pd.DataFrame(TEAM)
team_table["notebook_link"] = NOTEBOOK_URL
team_table["comment"] = team_table["name"] + " / " + team_table["github"]
cat_mark_table = team_table[["cat_id","notebook_link","coeff","comment"]]
show_table(cat_mark_table)
if team_table[["cat_id","coeff"]].isna().any().any():
show_md("Для завершения таблицы участников нам нужно заполнить `cat_id` и `coeff` в `TEAM`.")| cat_id | notebook_link | coeff | comment | |
|---|---|---|---|---|
| 0 | 502585 | https://github.com/Mironiks007/fifty-shades-of... | 0.5 | Петров Мирон / Mironiks007 |
| 1 | 501572 | https://github.com/Mironiks007/fifty-shades-of... | 0.5 | Каплунова Анна / kaplunovaania |
10.4 Источники
- TidyTuesday: таблица, словарь и цепочка очистки
- Первичный код: перевод влажности из процентов в долю
- University of Hawai‘i: измерение влажности кофе
- SciPy: перестановки пар
- Coffee Quality Database
- SciPy: коэффициент Спирмена
Версии библиотек вывожу в начале работы. Для повторных выборок и перестановок задаём фиксированные seed.