Загрузка данных


"""
Скелет вычислительного эксперимента к ВКР:
"Бюджетное распределение превентивного контроля по ожидаемому ущербу
от поздних дефектов в проектах мобильной разработки".

Что делает скрипт
-----------------
1. Генерирует полусинтетический «мир» НЕЗАВИСИМЫМ генератором: истинная
   вероятность позднего дефекта зависит от скрытого размера задачи, компонента,
   опыта исполнителя и новизны через cloglog-связь (а НЕ через логистическую
   функцию от Story Points). Story Points — зашумлённая оценка размера.
2. Обучает модель вероятности (логистическая регрессия) только на признаках,
   доступных при планировании (SP; опционально — компонент).
3. Сравнивает политики распределения усиленного контроля при ОДНОМ И ТОМ ЖЕ
   бюджете B и в ОДНОМ И ТОМ ЖЕ мире (истинные P, C, g), то есть различается
   только правило решения.
4. Считает метрики: истинные ожидаемые затраты, предотвращённый ущерб,
   recall@budget, регрет к оракулу, Brier/ECE/ROC-AUC/PR-AUC модели.
5. Сохраняет таблицы в CSV. Числа, которые выдаёт скрипт, — результаты ВАШЕГО
   запуска при ВАШИХ параметрах; параметры по умолчанию — допущения-заглушки.

Запуск
------
    python experiment_skeleton.py --reps 50 --sprints 30 --out results
    python experiment_skeleton.py --legacy        # проверочный расчёт примера §2.4 ВКР
    python experiment_skeleton.py --sweep budget  # свип по бюджету
    python experiment_skeleton.py --sweep k_mismatch
    python experiment_skeleton.py --sweep cost_cv
    python experiment_skeleton.py --sweep threshold
    python experiment_skeleton.py --sweep miscalibration --budget-fixed 5000
    python experiment_skeleton.py --sweep shift
    python experiment_skeleton.py --control-check   # частный случай = пороговое правило

Зависимости: numpy, scipy, pandas, scikit-learn (Python 3.10+).
"""
from __future__ import annotations

import argparse
import dataclasses
import os
from dataclasses import dataclass, replace

import numpy as np
import pandas as pd
from scipy import stats
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, brier_score_loss, roc_auc_score

FIB = np.array([1, 2, 3, 5, 8, 13, 21])
COMPONENTS = ("ui", "core", "network", "payments", "auth")


# ----------------------------------------------------------------------------
# 1. Конфигурация мира (все значения — допущения, подлежат свипу)
# ----------------------------------------------------------------------------
@dataclass(frozen=True)
class Config:
    n_hist: int = 6000                 # исторические задачи для обучения модели
    test_share: float = 0.30           # доля отложенной выборки для метрик модели
    n_sprint: int = 100                # задач в планируемом спринте
    base_rate: float = 0.15            # целевая доля поздних дефектов в мире
    comp_share: tuple = (0.40, 0.30, 0.15, 0.10, 0.05)
    comp_kappa: tuple = (1.0, 2.0, 2.0, 8.0, 6.0)        # критичность компонента
    comp_prod_share: tuple = (0.25, 0.30, 0.35, 0.40, 0.40)  # доля production среди поздних
    comp_shift: tuple = (0.0, 0.2, 0.3, 0.4, 0.3)        # вклад компонента в P
    C_qa: float = 1500.0               # стоимость позднего дефекта, найденного в QA
    C_prod: float = 6000.0             # стоимость дефекта в production
    cost_cv: float = 0.3               # индивидуальный разброс стоимости (лог-норм. сигма)
    cost_est_noise: float = 0.0        # ошибка оценки C в решении (лог-норм. сигма)
    c0: float = 500.0                  # стоимость полного усиленного контроля
    c_gamma: float = 0.0               # c_i = c0*(SP/5)^gamma; 0 -> константа как в ВКР
    k_true: float = 1.6                # истинная эффективность: g(x)=exp(-k x)
    k_mismatch: float = 1.0            # k_assumed = k_true * k_mismatch
    sp_noise: float = 0.35             # шум оценки SP (лог-шкала)
    use_component_feature: bool = False  # расширенная модель: SP + компонент
    threshold_T: float = 0.25          # порог базовой линии (2)
    budget_mult: float = 1.0           # B = budget_mult * (расход пороговой политики)
    budget_fixed: float | None = None  # если задан, B не зависит от пороговой политики
                                       # (нужно для свипов по T и по калибровке)
    robust_z: float = 1.2816           # квантиль для верхней границы P (90%)
    miscal_temp: float = 1.0           # искажение калибровки: logit -> t*logit
    miscal_shift: float = 0.0          # и сдвиг: + b
    shift_eta: float = 0.0             # сдвиг распределения: рост истинного риска в спринтах
    shift_logsize: float = 0.0         # сдвиг распределения: задачи в спринтах крупнее


# ----------------------------------------------------------------------------
# 2. Генератор мира (независимый от модели)
# ----------------------------------------------------------------------------
def _nearest_fib(v: np.ndarray) -> np.ndarray:
    idx = np.abs(np.log(v)[:, None] - np.log(FIB)[None, :]).argmin(axis=1)
    return FIB[idx]


def _latent(cfg: Config, n: int, rng: np.random.Generator, size_shift: float = 0.0) -> dict:
    size = rng.lognormal(mean=np.log(3.0) + size_shift, sigma=0.8, size=n)
    sp = _nearest_fib(size * np.exp(rng.normal(0.0, cfg.sp_noise, n)))
    comp = rng.choice(len(COMPONENTS), size=n, p=np.array(cfg.comp_share))
    senior = rng.random(n) < 0.6
    novelty = rng.random(n) < 0.2
    eta_wo_a0 = (0.9 * np.log(size) + np.array(cfg.comp_shift)[comp]
                 + 0.6 * novelty - 0.5 * senior)
    return dict(size=size, sp=sp, comp=comp, eta=eta_wo_a0)


def calibrate_intercept(cfg: Config, seed: int = 12345) -> float:
    """Подбирает a0, чтобы средняя истинная P в мире равнялась base_rate."""
    lat = _latent(cfg, 200_000, np.random.default_rng(seed))
    lo, hi = -10.0, 5.0
    for _ in range(80):
        mid = 0.5 * (lo + hi)
        p = 1.0 - np.exp(-np.exp(mid + lat["eta"]))
        lo, hi = (mid, hi) if p.mean() < cfg.base_rate else (lo, mid)
    return 0.5 * (lo + hi)


def make_tasks(cfg: Config, n: int, a0: float, rng: np.random.Generator,
               size_shift: float = 0.0) -> pd.DataFrame:
    lat = _latent(cfg, n, rng, size_shift)
    p_true = 1.0 - np.exp(-np.exp(a0 + lat["eta"]))           # cloglog-связь
    comp = lat["comp"]
    q = np.array(cfg.comp_prod_share)[comp]
    kappa = np.array(cfg.comp_kappa)[comp]
    c_expected = kappa * ((1 - q) * cfg.C_qa + q * cfg.C_prod)   # известна по компоненту
    s = cfg.cost_cv
    c_true = c_expected * rng.lognormal(-s * s / 2, s, n)        # индивидуальный разброс
    c_prev = cfg.c0 * (lat["sp"] / 5.0) ** cfg.c_gamma
    y = (rng.random(n) < p_true).astype(int)
    return pd.DataFrame(dict(sp=lat["sp"], comp=comp, p_true=p_true, y=y,
                             C_exp=c_expected, C_true=c_true, c_prev=c_prev))


# ----------------------------------------------------------------------------
# 3. Модель вероятности и её качество
# ----------------------------------------------------------------------------
def design(df: pd.DataFrame, cfg: Config) -> np.ndarray:
    cols = [df["sp"].to_numpy(float)]
    if cfg.use_component_feature:
        for j in range(1, len(COMPONENTS)):
            cols.append((df["comp"].to_numpy() == j).astype(float))
    return np.column_stack(cols)


class ProbModel:
    """Логистическая регрессия без SMOTE; дельта-метод для верхней границы P."""

    def __init__(self, cfg: Config):
        self.cfg = cfg
        self.lr = LogisticRegression(C=1e6, max_iter=2000)

    def fit(self, df: pd.DataFrame) -> "ProbModel":
        X = design(df, self.cfg)
        self.lr.fit(X, df["y"].to_numpy())
        Xa = np.column_stack([np.ones(len(X)), X])
        p = self.lr.predict_proba(X)[:, 1]
        info = Xa.T @ (Xa * (p * (1 - p))[:, None])
        self.cov = np.linalg.pinv(info)
        return self

    def _logit(self, df: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:
        X = design(df, self.cfg)
        Xa = np.column_stack([np.ones(len(X)), X])
        z = Xa @ np.r_[self.lr.intercept_, self.lr.coef_.ravel()]
        se = np.sqrt(np.einsum("ij,jk,ik->i", Xa, self.cov, Xa))
        return z, se

    def predict(self, df: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:
        z, se = self._logit(df)
        z = self.cfg.miscal_temp * z + self.cfg.miscal_shift      # искажение калибровки
        p_hat = 1 / (1 + np.exp(-z))
        p_up = 1 / (1 + np.exp(-(z + self.cfg.robust_z * se)))
        return p_hat, p_up


def ece(y: np.ndarray, p: np.ndarray, bins: int = 10) -> float:
    edges = np.linspace(0, 1, bins + 1)
    idx = np.clip(np.digitize(p, edges) - 1, 0, bins - 1)
    total = 0.0
    for b in range(bins):
        m = idx == b
        if m.any():
            total += m.mean() * abs(y[m].mean() - p[m].mean())
    return float(total)


def model_quality(model: ProbModel, test: pd.DataFrame) -> dict:
    p_hat, _ = model.predict(test)
    y = test["y"].to_numpy()
    return dict(roc_auc=roc_auc_score(y, p_hat), pr_auc=average_precision_score(y, p_hat),
                brier=brier_score_loss(y, p_hat), ece=ece(y, p_hat),
                mean_p_hat=float(p_hat.mean()), base_rate=float(y.mean()))


# ----------------------------------------------------------------------------
# 4. Политики распределения контроля: каждая возвращает x in [0,1]^N
# ----------------------------------------------------------------------------
def g_exp(x: np.ndarray, k: float) -> np.ndarray:
    return np.exp(-k * x)


def alloc_budget_exp(L: np.ndarray, c: np.ndarray, k: float, B: float | None,
                     tol: float = 1e-9) -> tuple[np.ndarray, float]:
    """min sum c x + L exp(-k x)  s.t. sum c x <= B, 0<=x<=1. Возвращает (x, theta)."""
    L = np.maximum(L, 0.0)

    def x_of(theta: float) -> np.ndarray:
        ratio = L * k / (theta * c)
        with np.errstate(divide="ignore"):
            x = np.where(ratio > 1.0, np.log(ratio) / k, 0.0)
        return np.clip(x, 0.0, 1.0)

    x1 = x_of(1.0)
    if B is None or (c * x1).sum() <= B:
        return x1, 1.0
    lo, hi = 1.0, max(1.0, float((L * k / c).max())) * 1.0001
    while hi - lo > tol * lo:
        mid = 0.5 * (lo + hi)
        if (c * x_of(mid)).sum() > B:
            lo = mid
        else:
            hi = mid
    return x_of(hi), hi


def alloc_linear_greedy(L: np.ndarray, c: np.ndarray, r: float, B: float) -> np.ndarray:
    """Абляция «без убывающей отдачи»: g(x)=1-(1-r)x -> дробный рюкзак."""
    gain = L * (1 - r) - c                    # чистый выигрыш полного контроля
    order = np.argsort(-(L * (1 - r)) / c)
    x, spent = np.zeros_like(L), 0.0
    for i in order:
        if gain[i] <= 0 or spent >= B:
            continue
        take = min(1.0, (B - spent) / c[i])
        x[i], spent = take, spent + take * c[i]
    return x


def alloc_binary_knapsack_greedy(L, c, r, B):
    """Абляция «бинарный контроль»: x in {0,1}, жадно по w/c (точно при равных c)."""
    w = L * (1 - r) - c
    order = np.argsort(-w / c)
    x, spent = np.zeros_like(L), 0.0
    for i in order:
        if w[i] > 0 and spent + c[i] <= B + 1e-9:
            x[i], spent = 1.0, spent + c[i]
    return x


def alloc_proportional_sp(sp: np.ndarray, c: np.ndarray, B: float) -> np.ndarray:
    lo, hi = 0.0, 1.0 / sp.min()
    for _ in range(100):
        a = 0.5 * (lo + hi)
        if (c * np.minimum(1, a * sp)).sum() > B:
            hi = a
        else:
            lo = a
    return np.minimum(1, lo * sp)


def alloc_random(c: np.ndarray, B: float, rng: np.random.Generator) -> np.ndarray:
    x, spent = np.zeros_like(c), 0.0
    for i in rng.permutation(len(c)):
        if spent + c[i] <= B + 1e-9:
            x[i], spent = 1.0, spent + c[i]
    return x


def policies(df: pd.DataFrame, p_hat, p_up, cfg: Config, rng) -> dict:
    c = df["c_prev"].to_numpy(float)
    k_a = cfg.k_true * cfg.k_mismatch
    r_a = np.exp(-k_a)
    C_hat = df["C_exp"].to_numpy(float) * rng.lognormal(
        -cfg.cost_est_noise ** 2 / 2, cfg.cost_est_noise, len(df)) if cfg.cost_est_noise > 0 \
        else df["C_exp"].to_numpy(float)
    x_thr = (p_hat > cfg.threshold_T).astype(float)          # (2) текущий подход ВКР
    B = cfg.budget_fixed if cfg.budget_fixed is not None \
        else cfg.budget_mult * float((c * x_thr).sum())
    L_hat, L_up = p_hat * C_hat, p_up * C_hat
    out = {
        "1a_no_control": np.zeros(len(df)),
        "1b_uniform_same_B": np.full(len(df), min(1.0, B / c.sum())),
        "2_threshold_T": x_thr,
        "3_random_same_B": alloc_random(c, B, rng),
        "4_proportional_SP": alloc_proportional_sp(df["sp"].to_numpy(float), c, B),
        "5_proposed": alloc_budget_exp(L_hat, c, k_a, B)[0],
        "5r_proposed_robust": alloc_budget_exp(L_up, c, k_a, B)[0],
        "A1_no_cost_heterog": alloc_budget_exp(p_hat * C_hat.mean(), c, k_a, B)[0],
        "A2_no_budget": alloc_budget_exp(L_hat, c, k_a, None)[0],
        "A3_no_dimin_returns": alloc_linear_greedy(L_hat, c, r_a, B),
        "A5_binary_levels": alloc_binary_knapsack_greedy(L_hat, c, r_a, B),
        # оракул при том же бюджете B — верхняя граница для политик 1b–5, A1, A3, A5
        "oracle": alloc_budget_exp(df["p_true"].to_numpy() * df["C_true"].to_numpy(),
                                   c, cfg.k_true, B)[0],
        # оракул без бюджета — сравнивать только с A2 (у A2 расход не ограничен B)
        "oracle_no_budget": alloc_budget_exp(df["p_true"].to_numpy() * df["C_true"].to_numpy(),
                                             c, cfg.k_true, None)[0],
    }
    return out, B


# ----------------------------------------------------------------------------
# 5. Оценка политик в ИСТИННОМ мире (общие случайные числа)
# ----------------------------------------------------------------------------
def evaluate(df: pd.DataFrame, x: np.ndarray, cfg: Config, u: np.ndarray) -> dict:
    c = df["c_prev"].to_numpy(float)
    p, C = df["p_true"].to_numpy(), df["C_true"].to_numpy()
    p_res = p * g_exp(x, cfg.k_true)
    L_true = p * C
    exp_cost = float((c * x).sum() + (p_res * C).sum())
    realized_defects = u < p_res                      # CRN: один u на задачу для всех политик
    return dict(
        spend=float((c * x).sum()),
        exp_late_defects=float(p_res.sum()),
        exp_total_cost=exp_cost,
        avoided_loss=float((L_true * (1 - g_exp(x, cfg.k_true))).sum()),
        recall_at_budget_lossw=float(L_true[x > 1e-6].sum() / L_true.sum()),
        realized_cost=float((c * x).sum() + C[realized_defects].sum()),
    )


def run(cfg: Config, reps: int, sprints: int, seed: int = 42) -> tuple[pd.DataFrame, pd.DataFrame]:
    a0 = calibrate_intercept(cfg)
    rows, qrows = [], []
    for rep in range(reps):
        rng = np.random.default_rng([seed, rep])
        hist = make_tasks(cfg, cfg.n_hist, a0, rng)
        idx = rng.permutation(len(hist))
        n_te = int(cfg.test_share * len(hist))
        test, train = hist.iloc[idx[:n_te]], hist.iloc[idx[n_te:]]
        model = ProbModel(cfg).fit(train)
        qrows.append(dict(rep=rep, **model_quality(model, test)))
        for s in range(sprints):
            # спринты могут генерироваться со сдвигом относительно истории (устойчивость к сдвигу)
            sprint = make_tasks(cfg, cfg.n_sprint, a0 + cfg.shift_eta, rng, cfg.shift_logsize)
            p_hat, p_up = model.predict(sprint)
            pol, B = policies(sprint, p_hat, p_up, cfg, rng)
            u = rng.random(len(sprint))
            for name, x in pol.items():
                rows.append(dict(rep=rep, sprint=s, policy=name, budget=B,
                                 **evaluate(sprint, x, cfg, u)))
    return pd.DataFrame(rows), pd.DataFrame(qrows)


def summarize(res: pd.DataFrame, ref: str = "2_threshold_T") -> pd.DataFrame:
    """Средние по повторам и парные разности с базовой линией (2): 95% bootstrap ДИ + Wilcoxon."""
    per_rep = res.groupby(["rep", "policy"]).mean(numeric_only=True).reset_index()
    base = per_rep[per_rep.policy == ref].set_index("rep")["exp_total_cost"]
    oracle = per_rep[per_rep.policy == "oracle"].set_index("rep")["exp_total_cost"]
    out = []
    rng = np.random.default_rng(0)
    for name, g in per_rep.groupby("policy"):
        g = g.set_index("rep")
        d = (g["exp_total_cost"] - base).to_numpy()
        boot = [rng.choice(d, len(d)).mean() for _ in range(2000)]
        p_w = stats.wilcoxon(d).pvalue if name != ref and np.any(d != 0) else np.nan
        out.append(dict(
            policy=name,
            exp_total_cost=g["exp_total_cost"].mean(),
            spend=g["spend"].mean(),
            exp_late_defects=g["exp_late_defects"].mean(),
            avoided_loss=g["avoided_loss"].mean(),
            recall_at_budget_lossw=g["recall_at_budget_lossw"].mean(),
            regret_vs_oracle=(g["exp_total_cost"] - oracle).mean(),
            diff_vs_threshold=d.mean(),
            ci95_low=np.percentile(boot, 2.5), ci95_high=np.percentile(boot, 97.5),
            wilcoxon_p=p_w,
            realized_cost_p95=res[res.policy == name]["realized_cost"].quantile(0.95),
        ))
    return pd.DataFrame(out).sort_values("exp_total_cost")


# ----------------------------------------------------------------------------
# 6. Свипы чувствительности
# ----------------------------------------------------------------------------
SWEEPS = {
    "budget": ("budget_mult", [0.25, 0.5, 1.0, 1.5, 2.0]),
    "threshold": ("threshold_T", [0.10, 0.15, 0.20, 0.25, 0.30, 0.40]),
    "k_mismatch": ("k_mismatch", [0.5, 0.75, 1.0, 1.5, 2.0]),
    "cost_cv": ("cost_cv", [0.0, 0.3, 0.6, 1.0]),
    "cost_est_noise": ("cost_est_noise", [0.0, 0.25, 0.5, 1.0]),
    "miscalibration": ("miscal_temp", [0.5, 0.75, 1.0, 1.5, 2.0]),
    "underestimation": ("miscal_shift", [-1.0, -0.5, 0.0, 0.5]),
    "k_true": ("k_true", [0.5, 1.0, 1.6, 2.5]),
    "shift": ("shift_eta", [0.0, 0.25, 0.5]),
    "shift_size": ("shift_logsize", [0.0, 0.3, 0.6]),
}


def run_sweep(base: Config, key: str, reps: int, sprints: int, out: str) -> pd.DataFrame:
    field, values = SWEEPS[key]
    frames = []
    for v in values:
        cfg = replace(base, **{field: v})
        res, _ = run(cfg, reps, sprints)
        s = summarize(res)
        s.insert(0, field, v)
        frames.append(s)
    df = pd.concat(frames, ignore_index=True)
    df.to_csv(os.path.join(out, f"sweep_{key}.csv"), index=False)
    return df


# ----------------------------------------------------------------------------
# 7. Проверочный расчёт примера §2.4 текущей редакции ВКР (аудит)
# ----------------------------------------------------------------------------
def legacy_check() -> None:
    R = lambda c: 0.5 / (1 + np.exp(2.8 - 0.38 * c))
    counts = {1: 25, 2: 28, 3: 20, 5: 15, 8: 9, 13: 3}          # рис. 2.2, N=100
    sp = np.repeat(list(counts), list(counts.values())).astype(float)
    r = R(sp)
    hi = r > 0.25
    no_ctrl = r.sum()
    ml = r[~hi].sum() + 0.10 * hi.sum()
    print(f"Средний R по рис. 2.2: {r.mean():.4f} (в тексте база 0,15)")
    print(f"Ожид. поздних дефектов без контроля в ТОМ ЖЕ мире: {no_ctrl:.2f} -> {3000*no_ctrl:,.0f} у.е.")
    print(f"ML-правило T=0,25: {ml:.2f} дефектов, затраты {3000*ml + 500*hi.sum():,.0f} у.е.")
    print(f"Экономия к согласованной базе: {3000*no_ctrl - 3000*ml - 500*hi.sum():,.0f} у.е.")


def control_check(seed: int = 7) -> None:
    """Контрольное предсказание H1_0 (Утверждение 2б): при однородной цене дефекта,
    равных c_i, бинарном контроле и неактивном бюджете оптимальное правило
    совпадает с порогом T* = c / ((1 - r) C). Расхождение = ошибка реализации."""
    cfg = Config(cost_cv=0.0, comp_kappa=(1.0,) * 5, comp_prod_share=(0.3,) * 5)
    a0 = calibrate_intercept(cfg)
    rng = np.random.default_rng(seed)
    model = ProbModel(cfg).fit(make_tasks(cfg, cfg.n_hist, a0, rng))
    sprint = make_tasks(cfg, 500, a0, rng)
    p_hat, _ = model.predict(sprint)
    C = float(sprint["C_exp"].iloc[0]); c = sprint["c_prev"].to_numpy(float)
    r = np.exp(-cfg.k_true)
    x_thr = (p_hat > cfg.c0 / ((1 - r) * C)).astype(float)
    x_opt = alloc_binary_knapsack_greedy(p_hat * C, c, r, B=np.inf)
    print("T* =", round(cfg.c0 / ((1 - r) * C), 4), "| совпадение выбора:", bool(np.all(x_thr == x_opt)))


# ----------------------------------------------------------------------------
def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--reps", type=int, default=50)
    ap.add_argument("--sprints", type=int, default=30)
    ap.add_argument("--out", default="results")
    ap.add_argument("--sweep", choices=list(SWEEPS), default=None)
    ap.add_argument("--component-feature", action="store_true")
    ap.add_argument("--legacy", action="store_true")
    ap.add_argument("--budget-fixed", type=float, default=None,
                    help="фиксированный бюджет B на спринт, у.е. (для свипов threshold/miscalibration)")
    ap.add_argument("--control-check", action="store_true",
                    help="контрольное предсказание: частный случай должен совпасть с порогом")
    a = ap.parse_args()
    if a.legacy:
        legacy_check()
        return
    if a.control_check:
        control_check()
        return
    os.makedirs(a.out, exist_ok=True)
    cfg = Config(use_component_feature=a.component_feature, budget_fixed=a.budget_fixed)
    pd.Series(dataclasses.asdict(cfg)).to_csv(os.path.join(a.out, "config.csv"))
    if a.sweep:
        print(run_sweep(cfg, a.sweep, a.reps, a.sprints, a.out).to_string(index=False))
        return
    res, q = run(cfg, a.reps, a.sprints)
    res.to_csv(os.path.join(a.out, "raw.csv"), index=False)
    q.to_csv(os.path.join(a.out, "model_quality.csv"), index=False)
    s = summarize(res)
    s.to_csv(os.path.join(a.out, "summary.csv"), index=False)
    pd.set_option("display.width", 200)
    print(q.describe().loc[["mean", "std"]].to_string())
    print(s.to_string(index=False))


if __name__ == "__main__":
    main()