Дополнительный датасет: рестораны TripAdvisor (HoReCa)
Рейтинг заведения и число отзывов.
Авторы доп. задания (КТвД): Племяшов Ярослав (losos-1), Швалов Тихон (stenkich), Ильин Матвей (wizrdj)
Стиль оформления: «Тёмная обжарка» (тёмный фон, засечки, янтарно-жёлтый акцент).
Две случайные величины: рейтинг заведения (балл 0–5) и число отзывов (шт.).
Оглавление
- Данные и единицы
- Почему Rating и Number of Reviews — случайные величины (критерий 1)
- EDA и распределения (критерий 4)
- Величина 1 — Rating
- Величина 2 — Number of Reviews
- Математическая база: эмпирические и модельные распределения (критерий 6)
- Зависимость и проверка гипотезы (критерии 2 и 7) — главный раздел
- Состоятельность оценок (критерий 3)
- Генеральная совокупность и два способа отбора (критерий 5)
- Интерактивный генератор (marimo)
- Итоги
</> показать код
#@title Импорты, загрузка данных, проверка sha256 и чистка
%matplotlib inline
import hashlib, os, io, urllib.request, warnings
import numpy as np
import pandas as pd
from scipy import stats
import matplotlib.pyplot as plt
import lab_style
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
SHA256 = "73fb6c804f801485fd84be8c2814181f306ae925e09030309a2a5744b2aeb1fe"
URL = "https://raw.githubusercontent.com/Ananyadevaraju/TripAdvisor---Sentiment-Analysis/master/TA_restaurants_curated.csv"
LOCAL = "TA_restaurants_curated.csv"
def load_raw_bytes():
# Сначала локальная копия рядом с блокнотом, иначе — скачивание по URL.
if os.path.exists(LOCAL):
data = open(LOCAL, "rb").read()
src = "локальная копия"
else:
with urllib.request.urlopen(URL) as resp:
data = resp.read()
src = "скачано по URL"
digest = hashlib.sha256(data).hexdigest()
assert digest == SHA256, f"sha256 не совпал: {digest}"
return data, src
raw, data_src = load_raw_bytes()
df = pd.read_csv(io.BytesIO(raw))
# Проверка колонок (структура файла):
need = {"Name", "City", "Rating", "Number of Reviews"}
assert need <= set(df.columns), f"нет колонок: {need - set(df.columns)}"
# Чистка: рейтинг > 0 (−1 и пусто = нет данных) И число отзывов >= 2, обе не пусты.
raw_rows = len(df)
d = (df[(df["Rating"] > 0) & (df["Number of Reviews"] >= 2)]
.dropna(subset=["Rating", "Number of Reviews"])
.copy())
d["ln_reviews"] = np.log(d["Number of Reviews"])
# Единый тёмный стиль «Тёмная обжарка», акцент horeca (жёлтый).
C = lab_style.apply_dark_style("horeca")
L = lab_style.LABELS
def rus(x, nd=None):
# Русская десятичная запятая для чисел в выводе.
if nd is not None:
x = round(float(x), nd)
return str(x).replace(".", ",")
print(f"Источник данных: {data_src}, sha256 проверен.")
print(f"Строк до чистки: {raw_rows}")
print(f"Строк после чистки (рабочий набор): {len(d)}")
print(f"Городов в наборе: {d['City'].nunique()}")Источник данных: локальная копия, sha256 проверен. Строк до чистки: 125527 Строк после чистки (рабочий набор): 108178 Городов в наборе: 31
1. Данные и единицы
Что такое одна строка. Каждая строка — один ресторан из набора TripAdvisor по 31 европейскому городу.
Нас интересуют два столбца:
- Rating — рейтинг заведения, балл по шкале $0{,}0\ldots5{,}0$. Величина дискретная, шаг $0{,}5$ (девять значений $1{,}0; 1{,}5; \ldots; 5{,}0$).
- Number of Reviews — число отзывов, целое число штук ($\ge 2$ после чистки).
Чистка. Оставляем строки, где $\text{Rating} > 0$ (значения $-1$ и пустые — отсутствие данных) и $\text{Number of Reviews} \ge 2$, обе величины не пусты. Полученный набор считаем генеральной совокупностью для дальнейших выборочных экспериментов.
</> показать код
#@title Таблица: размер набора до/после чистки и охват городов
summary = pd.DataFrame({
"Показатель": ["Строк в исходном файле",
"Строк после чистки (рабочий набор)",
"Отброшено при чистке",
"Городов",
"Диапазон Rating (балл)",
"Диапазон Number of Reviews (шт.)"],
"Значение": [len(df),
len(d),
len(df) - len(d),
d["City"].nunique(),
f"{d['Rating'].min():.1f} … {d['Rating'].max():.1f}",
f"{int(d['Number of Reviews'].min())} … {int(d['Number of Reviews'].max())}"]
})
summary| Показатель | Значение | |
|---|---|---|
| 0 | Строк в исходном файле | 125527 |
| 1 | Строк после чистки (рабочий набор) | 108178 |
| 2 | Отброшено при чистке | 17349 |
| 3 | Городов | 31 |
| 4 | Диапазон Rating (балл) | 1.0 … 5.0 |
| 5 | Диапазон Number of Reviews (шт.) | 2 … 16478 |
Вывод. После чистки остаётся рабочий набор из 108 178 ресторанов по 31 городу.
Rating лежит в $[1{,}0; 5{,}0]$ с шагом $0{,}5$, число отзывов — от $2$ до $16\,478$ штук.
Этот набор мы и считаем генеральной совокупностью.
2. Почему Rating и Number of Reviews — случайные величины (критерий 1)
Обе величины стохастичны: их значение нельзя вычислить детерминированной формулой заранее.
- Rating — это среднее субъективных оценок посетителей. Кто именно оставит отзыв, в каком настроении,
в какой день — случайно. Для $k$ оценок $X_1,\ldots,X_k$ наблюдаемый рейтинг
где каждая $X_i$ — случайная оценка посетителя. Поэтому $\widehat{R}$ — случайная величина.
- Number of Reviews — число людей, которые решили оставить отзыв. Оно зависит от потока гостей,
их желания писать, возраста заведения, сезона — всё это случайные факторы.
Значит, обе величины — реализации случайных процессов, и к ним применим аппарат теории вероятностей.
3. EDA и распределения (критерий 4)
Разведочный анализ по обеим величинам: сводные статистики, гистограммы, ECDF, боксплоты.
Отдельно показываем дискретность рейтинга, тяжёлый правый хвост числа отзывов
и лог-преобразование, приближающее число отзывов к нормальному.
</> показать код
#@title Сводные статистики по обеим величинам
desc = d[["Rating", "Number of Reviews", "ln_reviews"]].describe(
percentiles=[0.1, 0.25, 0.5, 0.75, 0.9]).round(3)
desc| Rating | Number of Reviews | ln_reviews | |
|---|---|---|---|
| count | 108178.000 | 108178.000 | 108178.000 |
| mean | 3.989 | 125.191 | 3.517 |
| std | 0.625 | 310.839 | 1.653 |
| min | 1.000 | 2.000 | 0.693 |
| 10% | 3.000 | 4.000 | 1.386 |
| 25% | 3.500 | 9.000 | 2.197 |
| 50% | 4.000 | 32.000 | 3.466 |
| 75% | 4.500 | 114.000 | 4.736 |
| 90% | 4.500 | 315.000 | 5.753 |
| max | 5.000 | 16478.000 | 9.710 |
</> показать код
#@title Rating: распределение (столбцы по 9 дискретным значениям) и ECDF
vc = d["Rating"].value_counts().sort_index()
fig, (ax1, ax2) = lab_style.styled_fig(1, 2, figsize=(12, 4.5))
ax1.bar(vc.index, vc.values, width=0.35, color=C["accent"], edgecolor=C["axis"])
ax1.set_xlabel(L["rating"]); ax1.set_ylabel("Число ресторанов (шт.)")
ax1.set_title("Распределение рейтинга (дискретное, шаг 0,5)")
ax1.set_xticks(np.arange(1.0, 5.5, 0.5))
xs = np.sort(d["Rating"].values)
ecdf = np.arange(1, len(xs) + 1) / len(xs)
ax2.step(xs, ecdf, where="post", color=C["second"])
ax2.set_xlabel(L["rating"]); ax2.set_ylabel("ECDF $F_n(x)$")
ax2.set_title("Эмпирическая функция распределения рейтинга")
plt.show()Вывод. Рейтинг принимает лишь девять значений с шагом $0{,}5$ — величина дискретная.
Масса сосредоточена на $4{,}0$ и $4{,}5$, низких оценок мало — распределение с левым перекосом
(длинный хвост влево), что и подтвердит отрицательная асимметрия.
</> показать код
#@title Number of Reviews: гистограмма в обычном и логарифмическом масштабе
fig, (ax1, ax2) = lab_style.styled_fig(1, 2, figsize=(12, 4.5))
ax1.hist(d["Number of Reviews"], bins=60, color=C["accent"], edgecolor=C["axis"])
ax1.set_xlabel(L["reviews"]); ax1.set_ylabel("Число ресторанов (шт.)")
ax1.set_title("Число отзывов — обычный масштаб (тяжёлый хвост)")
ax2.hist(d["Number of Reviews"], bins=np.logspace(np.log10(2), np.log10(d["Number of Reviews"].max()), 50),
color=C["second"], edgecolor=C["axis"])
ax2.set_xscale("log")
ax2.set_xlabel(L["reviews"] + " — лог-ось"); ax2.set_ylabel("Число ресторанов (шт.)")
ax2.set_title("Число отзывов — логарифмическая ось X")
plt.show()Вывод. В обычном масштабе почти всё прижато к нулю — виден тяжёлый правый хвост:
у немногих ресторанов тысячи отзывов. На логарифмической оси форма становится почти симметричной
«колоколом» — намёк на логнормальную природу числа отзывов.
</> показать код
#@title Боксплоты и лог-преобразование: ln(reviews) ≈ нормально (гистограмма + Q–Q)
fig, axes = lab_style.styled_fig(1, 3, figsize=(14, 4.3))
ax1, ax2, ax3 = axes
bp = ax1.boxplot([d["Rating"]], patch_artist=True, tick_labels=["Rating"])
for b in bp["boxes"]:
b.set_facecolor(C["panel"]); b.set_edgecolor(C["accent"])
ax1.set_ylabel(L["rating"]); ax1.set_title("Боксплот рейтинга")
ax2.hist(d["ln_reviews"], bins=50, density=True, color=C["second"], edgecolor=C["axis"])
mu, sd = d["ln_reviews"].mean(), d["ln_reviews"].std()
xx = np.linspace(d["ln_reviews"].min(), d["ln_reviews"].max(), 200)
ax2.plot(xx, stats.norm.pdf(xx, mu, sd), color=C["accent"], lw=2, label="норм. модель")
ax2.set_xlabel(L["log_reviews"]); ax2.set_ylabel("Плотность")
ax2.set_title("ln(число отзывов) ≈ нормально"); ax2.legend()
stats.probplot(d["ln_reviews"], dist="norm", plot=ax3)
ax3.get_lines()[0].set_color(C["second"]); ax3.get_lines()[0].set_markersize(2)
ax3.get_lines()[1].set_color(C["accent"])
ax3.set_xlabel("Теоретические квантили"); ax3.set_ylabel("Выборочные квантили")
ax3.set_title("Q–Q график ln(reviews)")
plt.show()
rmin, rmax = d['Rating'].min(), d['Rating'].max(); rmean = d['Rating'].mean()
print("Нормализация Rating (справочно):")
print(f" min-max в [0;1]: напр. среднее {rus(rmean,2)} балла -> {rus((rmean-rmin)/(rmax-rmin),3)}")
print(f" z-score: mean=0, sd=1 по построению (исходные mean={rus(rmean,3)}, sd={rus(d['Rating'].std(),3)})")
print(f"ln(reviews): mean={rus(mu,2)} sd={rus(sd,2)}")Нормализация Rating (справочно): min-max в [0;1]: напр. среднее 3,99 балла -> 0,747 z-score: mean=0, sd=1 по построению (исходные mean=3,989, sd=0,625) ln(reviews): mean=3,52 sd=1,65
Вывод. Боксплот подтверждает левый перекос рейтинга. Гистограмма $\ln(\text{reviews})$
ложится на нормальную кривую, а точки Q–Q идут вдоль прямой — логарифм числа отзывов близок к нормальному.
Это обосновывает дальнейшую работу с $\ln(\text{reviews})$ и логнормальную модель для числа отзывов.
Нормализации ($\min$-$\max$, $z$-оценка) приводят величины к сопоставимым шкалам.
4. Величина 1 — Rating
Оценка среднего рейтинга и доверительный интервал. При большом $n$ по ЦПТ
</> показать код
#@title Rating: среднее и 95% доверительный интервал
n = len(d); mean_r = d["Rating"].mean(); sd_r = d["Rating"].std(ddof=1)
se = sd_r / np.sqrt(n); z = 1.96
lo, hi = mean_r - z*se, mean_r + z*se
print(f"n = {n}")
print(f"Среднее Rating = {rus(mean_r,4)} балла")
print(f"Исправл. СКО (s) = {rus(sd_r,4)}")
print(f"Стандартная ошибка SE = {rus(se,5)}")
print(f"95% ДИ для среднего = [{rus(lo,4)}; {rus(hi,4)}] балла")
print(f"Асимметрия (skew) = {rus(stats.skew(d['Rating']),2)} (левый перекос)")
print("(ДИ трактуем как неопределённость среднего при взгляде на набор как на ВЫБОРКУ из более")
print(" широкой совокупности ресторанов; для него самого как полной популяции среднее известно точно.)")n = 108178 Среднее Rating = 3,9892 балла Исправл. СКО (s) = 0,6247 Стандартная ошибка SE = 0,0019 95% ДИ для среднего = [3,9854; 3,9929] балла Асимметрия (skew) = -0,91 (левый перекос) (ДИ трактуем как неопределённость среднего при взгляде на набор как на ВЫБОРКУ из более широкой совокупности ресторанов; для него самого как полной популяции среднее известно точно.)
Вывод. Средний рейтинг $\approx 4{,}03$ балла. Доверительный интервал крайне узкий
(порядка тысячных) — следствие огромного $n$: при $n\approx 10^5$ среднее оценивается очень точно.
Асимметрия $\approx -0{,}91$ численно подтверждает левый перекос.
5. Величина 2 — Number of Reviews
Число отзывов имеет тяжёлый хвост, поэтому среднее арифметическое неустойчиво, а медиана
и работа с $\ln(\text{reviews})$ информативнее.
</> показать код
#@title Number of Reviews: сводка и доверительный интервал для среднего ln(reviews)
rv = d["Number of Reviews"]
print(f"min={int(rv.min())} max={int(rv.max())} медиана={int(rv.median())} "
f"p90={int(rv.quantile(0.9))} среднее={rus(rv.mean(),2)} шт.")
print(f"Асимметрия Number of Reviews = {rus(stats.skew(rv),2)} (тяжёлый правый хвост)")
print(f"Асимметрия ln(reviews) = {rus(stats.skew(d['ln_reviews']),2)} (почти нормально)")
n = len(d); m = d["ln_reviews"].mean(); s = d["ln_reviews"].std(ddof=1)
se = s/np.sqrt(n)
print(f"\nln(reviews): среднее = {rus(m,4)} s = {rus(s,4)}")
print(f"95% ДИ для среднего ln(reviews) = [{rus(m-1.96*se,4)}; {rus(m+1.96*se,4)}]")
print(f"Обратный перевод exp(среднего ln) = {rus(np.exp(m),2)} шт. (геом. среднее ≈ медиана {int(rv.median())})")
print("(ДИ — неопределённость среднего при взгляде на набор как на выборку, см. пояснение выше.)")min=2 max=16478 медиана=32 p90=315 среднее=125,19 шт. Асимметрия Number of Reviews = 11,33 (тяжёлый правый хвост) Асимметрия ln(reviews) = 0,18 (почти нормально) ln(reviews): среднее = 3,5168 s = 1,6528 95% ДИ для среднего ln(reviews) = [3,5069; 3,5266] Обратный перевод exp(среднего ln) = 33,68 шт. (геом. среднее ≈ медиана 32) (ДИ — неопределённость среднего при взгляде на набор как на выборку, см. пояснение выше.)
Вывод. Среднее число отзывов ($\approx 125$) намного больше медианы ($32$) — классический признак
тяжёлого правого хвоста: редкие «гиганты» тянут среднее вверх. Геометрическое среднее $\exp(\overline{\ln})\approx 34$
близко к медиане — ещё одно подтверждение логнормальной формы. Асимметрия падает с $+11{,}3$ (сырое)
до $+0{,}18$ ($\ln$) — логарифм «выпрямляет» распределение.
6. Математическая база: эмпирические и модельные распределения (критерий 6)
- Для Rating строим эмпирическую PMF (дискретную) и CDF.
- Для Number of Reviews проверяем логнормальную модель: параметры оцениваем по $\ln$,
сравниваем Q–Q и считаем согласие (критерий Колмогорова–Смирнова).
</> показать код
#@title Rating: эмпирическая PMF и CDF
vc = d["Rating"].value_counts().sort_index()
pmf = vc / vc.sum(); cdf = pmf.cumsum()
fig, (ax1, ax2) = lab_style.styled_fig(1, 2, figsize=(12, 4.3))
ax1.stem(pmf.index, pmf.values, basefmt=" ")
ax1.set_xlabel(L["rating"]); ax1.set_ylabel("Вероятность $p(x)$")
ax1.set_title("Эмпирическая PMF рейтинга")
ax2.step(cdf.index, cdf.values, where="post", color=C["accent"])
ax2.set_xlabel(L["rating"]); ax2.set_ylabel("$F(x)$")
ax2.set_title("Эмпирическая CDF рейтинга")
plt.show()
for k, v in pmf.items():
print(f" P(Rating={rus(k,1)}) = {rus(v,4)}")P(Rating=1,0) = 0,0014 P(Rating=1,5) = 0,0035 P(Rating=2,0) = 0,0095 P(Rating=2,5) = 0,0251 P(Rating=3,0) = 0,069 P(Rating=3,5) = 0,1825 P(Rating=4,0) = 0,3451 P(Rating=4,5) = 0,2896 P(Rating=5,0) = 0,0743
</> показать код
#@title Number of Reviews: проверка логнормальной модели (Q–Q по ln + K–S)
mu, sd = d["ln_reviews"].mean(), d["ln_reviews"].std(ddof=1)
# K-S согласие ln(reviews) с нормальным: стандартизуем и сравним со стандартным N(0,1).
z_ln = (d["ln_reviews"] - mu) / sd
ks = stats.kstest(z_ln, stats.norm.cdf)
fig, (ax1, ax2) = lab_style.styled_fig(1, 2, figsize=(12, 4.3))
ax1.hist(d["Number of Reviews"],
bins=np.logspace(np.log10(2), np.log10(d["Number of Reviews"].max()), 50),
density=True, color=C["second"], edgecolor=C["axis"], label="эмпирика")
xx = np.logspace(np.log10(2), np.log10(d["Number of Reviews"].max()), 300)
ax1.plot(xx, stats.lognorm.pdf(xx, s=sd, scale=np.exp(mu)), color=C["accent"], lw=2,
label="логнормальная модель")
ax1.set_xscale("log")
ax1.set_xlabel(L["reviews"] + " — лог-ось"); ax1.set_ylabel("Плотность")
ax1.set_title("Логнормальная модель числа отзывов"); ax1.legend()
stats.probplot(d["ln_reviews"], dist="norm", sparams=(mu, sd), plot=ax2)
ax2.get_lines()[0].set_color(C["second"]); ax2.get_lines()[0].set_markersize(2)
ax2.get_lines()[1].set_color(C["accent"])
ax2.set_xlabel("Теоретические квантили"); ax2.set_ylabel("Выборочные квантили")
ax2.set_title("Q–Q: ln(reviews) против нормального")
plt.show()
print(f"Оценки: mu = {rus(mu,3)}, sigma = {rus(sd,3)}")
print(f"K–S статистика = {rus(ks.statistic,4)} (D мал → форма близка к логнормальной)")Оценки: mu = 3,517, sigma = 1,653 K–S статистика = 0,0443 (D мал → форма близка к логнормальной)
Вывод. Эмпирическая PMF рейтинга — девять столбиков, суммарно $1$; масса на $4{,}0$–$4{,}5$.
Для числа отзывов логнормальная модель хорошо ложится на гистограмму, а Q–Q идёт вдоль прямой —
модель $\text{Lognormal}(\mu\approx3{,}52,\ \sigma\approx1{,}65)$ адекватна. Небольшие отклонения K–S
объяснимы дискретностью и усечением $\ge 2$.
7. Зависимость и проверка гипотезы (критерии 2 и 7) — главный раздел
Обе величины — сигналы признания заведения, поэтому естественно спросить: связаны ли они?
Гипотеза $H_0$: Rating и Number of Reviews независимы. Альтернатива $H_1$: есть монотонная связь.
Считаем ранговую корреляцию Спирмена $\rho_s$, Пирсона $r$, оцениваем эффект-сайз $\rho_s^2$,
проводим перестановочный тест и $\chi^2$-тест на биннированных величинах, а главное — показываем,
как статистическая значимость зависит от объёма выборки, а не от силы связи.
</> показать код
#@title Корреляции, эффект-сайз и p-value на полном наборе
r_pr = stats.pearsonr(d["Rating"], d["Number of Reviews"])
r_prln = stats.pearsonr(d["Rating"], d["ln_reviews"])
rho = stats.spearmanr(d["Rating"], d["Number of Reviews"])
R2 = rho.statistic**2
print(f"Пирсон r(Rating, Number) = {rus(r_pr.statistic,4)}")
print(f"Пирсон r(Rating, ln reviews) = {rus(r_prln.statistic,4)}")
print(f"Спирмен rho_s(Rating, Number) = {rus(rho.statistic,4)}")
print(f"p-value на полном наборе (n={len(d)}) = {rho.pvalue:.3e}")
print(f"Эффект-сайз: |rho_s| = {rus(abs(rho.statistic),4)} — ранговая связь ничтожна")
print(f" (rho_s^2 = {rus(R2,4)}, т.е. {rus(R2*100,2)}%; это НЕ доля дисперсии как r^2 Пирсона, а мера крошечности ранговой связи)")Пирсон r(Rating, Number) = 0,0339 Пирсон r(Rating, ln reviews) = 0,0018 Спирмен rho_s(Rating, Number) = -0,0511 p-value на полном наборе (n=108178) = 1.854e-63 Эффект-сайз: |rho_s| = 0,0511 — ранговая связь ничтожна (rho_s^2 = 0,0026, т.е. 0,26%; это НЕ доля дисперсии как r^2 Пирсона, а мера крошечности ранговой связи)
Первый факт. Корреляции почти нулевые: $\rho_s\approx-0{,}05$, $r\approx0{,}03$.
Эффект-сайз $\rho_s^2\approx0{,}0026$ — связь объясняет лишь $0{,}26\%$ разброса, то есть практически ничтожна.
При этом $p\approx1{,}5\cdot10^{-63}$ — формально «дико значимо». Разберёмся, почему так.
</> показать код
#@title Диаграмма рассеяния Rating vs ln(reviews) с усреднением по бинам
fig, ax = lab_style.styled_fig(figsize=(9, 5.2))
idx = np.random.RandomState(0).choice(len(d), size=8000, replace=False)
ax.scatter(d["ln_reviews"].values[idx], d["Rating"].values[idx],
s=6, alpha=0.08, color=C["second"], label="рестораны (подвыборка)")
# среднее Rating по бинам ln(reviews):
bins = np.linspace(d["ln_reviews"].min(), d["ln_reviews"].max(), 14)
cat = pd.cut(d["ln_reviews"], bins)
gm = d.groupby(cat)["Rating"].mean()
centers = [(b.left + b.right) / 2 for b in gm.index]
ax.plot(centers, gm.values, "-o", color=C["accent"], lw=2, label="среднее Rating по бинам")
ax.set_xlabel(L["log_reviews"]); ax.set_ylabel(L["rating"])
ax.set_title("Rating против ln(числа отзывов): связь почти горизонтальна")
ax.legend()
plt.show()Вывод по графику. Облако точек бесформенное, линия средних по бинам почти горизонтальна —
визуально зависимости нет. Лёгкий наклон вниз на правом крае согласуется со слабой отрицательной $\rho_s\approx-0{,}05$.
</> показать код
#@title Перестановочный тест по |rho_s| (подвыборка n=2000)
rng = np.random.RandomState(42)
sub = d.sample(2000, random_state=1)
obs = abs(stats.spearmanr(sub["Rating"], sub["Number of Reviews"]).statistic)
a = sub["Rating"].values; b = sub["Number of Reviews"].values
B = 2000; cnt = 0
for _ in range(B):
perm = rng.permutation(b)
if abs(stats.spearmanr(a, perm).statistic) >= obs:
cnt += 1
p_perm = (cnt + 1) / (B + 1)
print(f"Подвыборка n=2000: |rho_s| набл. = {rus(obs,4)}")
print(f"Перестановочный p (B={B}) = {rus(p_perm,4)}")
print("При n=2000 тест ещё отвергает H0, но на меньших n (300/1000) результат")
print("неустойчив — то значимо, то нет (см. таблицу ниже). Доминирует шум выборки.")Подвыборка n=2000: |rho_s| набл. = 0,0682 Перестановочный p (B=2000) = 0,001 При n=2000 тест ещё отвергает H0, но на меньших n (300/1000) результат неустойчив — то значимо, то нет (см. таблицу ниже). Доминирует шум выборки.
</> показать код
#@title Устойчивость значимости: p-value на 5 случайных подвыборках при разных n
rng = np.random.RandomState(7)
rows = []
for n in [300, 1000, 5000]:
ps = []
for _ in range(5):
s = d.sample(n, random_state=rng.randint(1_000_000))
ps.append(stats.spearmanr(s["Rating"], s["Number of Reviews"]).pvalue)
rows.append([n, min(ps), max(ps), sum(p < 0.05 for p in ps)])
tab = pd.DataFrame(rows, columns=["n", "p_min", "p_max", "значимо из 5 (p<0,05)"])
tab["p_min"] = tab["p_min"].map(lambda x: rus(x, 4))
tab["p_max"] = tab["p_max"].map(lambda x: rus(x, 4))
print(tab.to_string(index=False))n p_min p_max значимо из 5 (p<0,05) 300 0,0505 0,9629 0 1000 0,0015 0,9805 1 5000 0,0 0,0386 5
</> показать код
#@title График: типичное p-value падает с ростом n (эффект фиксирован)
rng = np.random.RandomState(11)
ns = [100, 300, 1000, 3000, 10000, 30000, 100000]
med_p = []
for n in ns:
n = min(n, len(d))
ps = [stats.spearmanr(s["Rating"], s["Number of Reviews"]).pvalue
for s in (d.sample(n, random_state=rng.randint(1_000_000)) for _ in range(9))]
med_p.append(np.median(ps))
fig, ax = lab_style.styled_fig(figsize=(9, 5))
ax.plot(ns, med_p, "-o", color=C["accent"], label="медианное p (9 подвыборок)")
ax.axhline(0.05, color=C["second"], ls="--", lw=2, label="порог значимости 0,05")
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("Объём выборки n (шт. ресторанов)")
ax.set_ylabel("p-value (лог-ось)")
ax.set_title("Значимость — функция n: эффект тот же, p падает с ростом n")
ax.legend()
plt.show()</> показать код
#@title chi-квадрат тест независимости на биннированных величинах
rbin = pd.cut(d["Rating"], bins=[0, 3.0, 3.5, 4.0, 4.5, 5.0])
nbin = pd.qcut(d["Number of Reviews"], q=5, duplicates="drop")
ct = pd.crosstab(rbin, nbin)
chi2, p_chi, dof, _ = stats.chi2_contingency(ct)
print(f"chi^2 = {rus(chi2,1)} dof = {dof} p = {p_chi:.3e}")
print("chi^2 тоже отвергает независимость — но лишь из-за огромного n;")
print("сила связи (Крамер V) мала:")
V = np.sqrt(chi2 / (len(d) * (min(ct.shape) - 1)))
print(f" Крамер V = {rus(V,4)} (намного меньше 1 — связь слабая, практической зависимости нет)")chi^2 = 10924,7 dof = 16 p = 0.000e+00 chi^2 тоже отвергает независимость — но лишь из-за огромного n; сила связи (Крамер V) мала: Крамер V = 0,1589 (намного меньше 1 — связь слабая, практической зависимости нет)
Ключевой вывод (критерии 2 и 7)
- Эффект ничтожен всегда: $\rho_s^2\approx0{,}0026$ — связь объясняет $\approx0{,}26\%$ разброса,
а $\chi^2$-тест даёт крошечный Крамер $V$. Практически Rating и Number of Reviews независимы.
- Значимость — функция размера выборки, а не силы связи:
- (а) на полном наборе $n\approx108\,000$: $p\approx1{,}5\cdot10^{-63}$ — «дико значимо», хотя связь ничтожна;
- (б) на подвыборках $n=300$ и $n=1000$ результат неустойчив — то значимо, то нет (доминирует шум выборки);
при $n=5000$ становится почти всегда значимо;
- (в) «статистически значимо» $\ne$ «практически зависимы». При огромном $n$ значимым становится
любой мизерный эффект. Решает эффект-сайз, а не $p$-value.
- Нулевая корреляция сама по себе не доказывает независимость (Пирсон ловит только линейную связь),
поэтому мы добавили ранговый $\rho_s$ (монотонная связь) и $\chi^2$ (произвольная, в т.ч. немонотонная) —
все три согласованно говорят: значимой практической зависимости нет, есть лишь пренебрежимая
отрицательная тенденция $\approx-0{,}05$.
8. Состоятельность оценок (критерий 3)
Оценка состоятельна, если с ростом $n$ она сходится к истинному значению. Теория даёт
Проверим пересэмплингом с возвращением из нашего набора (генеральной совокупности).
</> показать код
#@title RMSE оценки среднего падает как 1/sqrt(n) — сверка с теорией
rng = np.random.RandomState(3)
fig, (ax1, ax2) = lab_style.styled_fig(1, 2, figsize=(12, 4.6))
for col, ax, color, name in [("Rating", ax1, C["accent"], "Rating"),
("ln_reviews", ax2, C["second"], "ln(reviews)")]:
vals = d[col].values
true_mean = vals.mean(); sigma = vals.std(ddof=0)
ns = [10, 30, 100, 300, 1000, 3000]
rmse = []
for n in ns:
means = [rng.choice(vals, size=n, replace=True).mean() for _ in range(400)]
rmse.append(np.sqrt(np.mean((np.array(means) - true_mean) ** 2)))
ax.plot(ns, rmse, "o-", color=color, label="RMSE (пересэмплинг)")
ax.plot(ns, sigma / np.sqrt(ns), "--", color=C["muted"], label="теория σ/√n")
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("Объём выборки n (шт.)"); ax.set_ylabel("RMSE среднего")
ax.set_title(f"Состоятельность среднего: {name}"); ax.legend()
plt.show()
print("Эмпирический RMSE ложится на теоретическую 1/√n — оценки состоятельны.")Эмпирический RMSE ложится на теоретическую 1/√n — оценки состоятельны.
Вывод. Эмпирический RMSE среднего совпадает с теоретической линией $\sigma/\sqrt{n}$
и стремится к нулю — выборочное среднее состоятельно для обеих величин.
9. Генеральная совокупность и два способа отбора (критерий 5)
Набор из $108\,178$ ресторанов — наша генеральная совокупность. Сравним выборки с возвращением
и без возвращения при одном $n$. Для отбора без возвращения дисперсия оценки умножается на
поправку на конечность совокупности (FPC):
</> показать код
#@title Выборки с возвращением и без; FPC; расчёт объёма по точности
rng = np.random.RandomState(5)
vals = d["Rating"].values
N = len(vals); n = 1000
with_rep = [rng.choice(vals, size=n, replace=True).mean() for _ in range(2000)]
without_rep = [rng.choice(vals, size=n, replace=False).mean() for _ in range(2000)]
fpc = (N - n) / (N - 1)
print(f"N = {N}, n = {n}")
print(f"С возвращением: среднее оценок = {rus(np.mean(with_rep),4)} дисперсия = {rus(np.var(with_rep),6)}")
print(f"Без возвращения: среднее оценок = {rus(np.mean(without_rep),4)} дисперсия = {rus(np.var(without_rep),6)}")
print(f"Отношение дисперсий (без/с) = {rus(np.var(without_rep)/np.var(with_rep),4)} ~ FPC = {rus(fpc,4)}")
sigma = vals.std(ddof=0); z = 1.96; E = 0.01
n_need = (z * sigma / E) ** 2
print(f"\nДля точности E={rus(E,2)} балла (95%) нужно n = {int(np.ceil(n_need))} ресторанов")
fig, ax = lab_style.styled_fig(figsize=(9, 5))
ax.hist(with_rep, bins=40, alpha=0.7, color=C["accent"], label="с возвращением")
ax.hist(without_rep, bins=40, alpha=0.6, color=C["second"], label="без возвращения")
ax.axvline(vals.mean(), color=C["muted"], ls="--", lw=2, label="среднее генеральной")
ax.set_xlabel("Оценка среднего " + L["rating"]); ax.set_ylabel("Частота (шт.)")
ax.set_title("Распределение оценок среднего: два способа отбора (n=1000)")
ax.legend()
plt.show()N = 108178, n = 1000 С возвращением: среднее оценок = 3,9889 дисперсия = 0,000395 Без возвращения: среднее оценок = 3,9888 дисперсия = 0,000362 Отношение дисперсий (без/с) = 0,916 ~ FPC = 0,9908 Для точности E=0,01 балла (95%) нужно n = 14992 ресторанов
Вывод. Обе схемы дают несмещённую оценку среднего (центры совпадают с генеральным).
При $n\ll N$ поправка FPC $\approx1$, поэтому дисперсии почти равны — разница между «с возвращением»
и «без» здесь ничтожна. Формула объёма показывает, сколько наблюдений нужно для заданной точности.
10. Интерактивный генератор (marimo)
Живой интерактив вынесен в отдельное marimo-приложение marimo_generator.py
(выпадашка величины, слайдер объёма $n$, чекбокс «с возвращением / без», поле seed;
вывод — ECDF выборки против генеральной + таблица статистик). Запуск:
```bash
marimo run marimo_generator.py
```
Ниже — один статичный пример для GitHub-рендера: как выборочная ECDF приближает генеральную.
</> показать код
#@title Статичный пример: ECDF выборки против генеральной совокупности
rng = np.random.RandomState(0)
vals = np.sort(d["Rating"].values)
pop_ecdf = np.arange(1, len(vals) + 1) / len(vals)
fig, ax = lab_style.styled_fig(figsize=(9, 5))
ax.step(vals, pop_ecdf, where="post", color=C["muted"], lw=2.5,
label=f"генеральная (N={len(vals)})")
for n, col in [(50, C["accent"]), (500, C["second"])]:
s = np.sort(rng.choice(d["Rating"].values, size=n, replace=False))
e = np.arange(1, n + 1) / n
ax.step(s, e, where="post", color=col, alpha=0.9, label=f"выборка n={n}")
ax.set_xlabel(L["rating"]); ax.set_ylabel("ECDF $F(x)$")
ax.set_title("Выборочная ECDF приближает генеральную (интерактив — в marimo)")
ax.legend()
plt.show()Вывод. Уже при $n=500$ выборочная ECDF почти сливается с генеральной — визуальная
иллюстрация состоятельности. В marimo этим можно управлять интерактивно (менять $n$, seed, способ отбора).
11. Итоги
11.1 Выводы
✍️ Черновик для команды (Тихон, Ярослав). Ниже — установленные факты и цифры из нашего анализа. Напишите выводы своими словами на их основе (за выводы, сгенерированные нейросетью, −20 %). Как на лабах по физике: что увидели и что это значит про рестораны.
Факты, на которые опираемся (все посчитаны в блокноте выше):
- Данные: 108 178 ресторанов TripAdvisor (31 европейский город) после чистки.
- Рейтинг — дискретная величина (9 значений, шаг 0,5), сильный левый перекос (асимметрия −0,91): большинство оценок 4,0–4,5.
- Число отзывов — тяжёлый правый хвост (от 2 до 16 478, медиана 32); его логарифм почти нормальный (асимметрия +0,18) → логнормальная модель.
- Зависимость рейтинг ↔ число отзывов практически отсутствует: ρ Спирмена = −0,05, ρ²Спирмена ≈ 0,26 % — ранговая связь ничтожна.
- При этом на полном наборе p ≈ 1,5·10⁻⁶³ («значимо»), а на выборках n = 300–1000 результат неустойчив → главный урок: при огромном n значимой становится любая мелочь; решает размер эффекта, а не p-value.
- χ²-тест независимости: слабая связь (V ≈ 0,16).
- Выборочные среднее и дисперсия состоятельны (ошибка падает ~1/√n); отбор без возвращения точнее при заметной доле выборки.
_Своими словами напишите:_ что это говорит о ресторанах (рейтинг и популярность-по-числу-отзывов почти не связаны), какие гипотезы подтвердились или опровергнуты.
11.2 Как использовали нейросеть
✍️ Команде дописать честно. Что известно точно:
- Оформление (тёмный стиль, векторные графики, оглавление и якоря, скрытый код), интерактивный генератор (marimo) и хаб-страница подготовлены с помощью Claude (Anthropic) по запросу дизайнеров.
- Выбор датасета, методику проверок и расчёты сверяли вручную по данным.
- _Где проверяли и спорили с нейросетью, что исправили руками:_ …
11.3 Источник данных и ссылки
- Датасет: TripAdvisor European restaurants (
TA_restaurants_curated.csv), $125\,527$ строк, $31$ город.
Зеркало: https://raw.githubusercontent.com/Ananyadevaraju/TripAdvisor---Sentiment-Analysis/master/TA_restaurants_curated.csv
(sha256 проверяется при загрузке).
- Стиль графиков: модуль
lab_style.py(«Тёмная обжарка», акцентhoreca). - Интерактив:
marimo_generator.py.
Участники доп. задания (КТвД)
| Участник | GitHub | cat_id | Коэфф. вклада |
|---|---|---|---|
| Племяшов Ярослав | losos-1 | ||
| Швалов Тихон | stenkich | ||
| Ильин Матвей | wizrdj |
*(cat_id и коэффициенты вклада заполняет команда перед сдачей.)*