{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Склеить таблицы\n",
    "\n",
    "pd.concat ставит таблицы друг под другом: выгрузки по полугодиям, файлы нескольких станций. Почему после склейки повторяются метки и зачем ignore_index; что бывает, когда столбцы разные; склейка столбцами.\n",
    "\n",
    "Курс pandas · урок `pd-concat` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/concat).\n",
    "\n",
    "Выполняйте ячейки по порядку (Shift+Enter), начиная с первой — она готовит урок. После каждого упражнения идёт\n",
    "ячейка проверки: она печатает ✓ / ✗ и итог."
   ],
   "id": "intro"
  },
  {
   "cell_type": "code",
   "metadata": {
    "cellView": "form",
    "jupyter": {
     "source_hidden": true
    }
   },
   "source": [
    "# @title Подготовка урока — выполните эту ячейку первой\n",
    "# Проверяет версии пакетов, записывает файлы данных в папку data/ и определяет _check — проверку упражнений.\n",
    "import base64, os, zlib\n",
    "\n",
    "for _name, _min in {\"numpy\":\"2.0\",\"pandas\":\"3.0\"}.items():\n",
    "    try:\n",
    "        _have = __import__(_name).__version__\n",
    "    except ImportError:\n",
    "        print(f\"Нет пакета {_name}: выполните %pip install {_name} и перезапустите ядро\")\n",
    "        continue\n",
    "    if tuple(int(x) for x in _have.split(\".\")[:2]) < tuple(int(x) for x in _min.split(\".\")):\n",
    "        print(f\"Урок написан для {_name} {_min} и новее, у вас {_have}: выполните %pip install -U {_name} и перезапустите ядро\")\n",
    "\n",
    "_DATA = {\n",
    "    \"delivery_h1.csv\": \"\"\"\n",
    "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\n",
    "/J3UMnmxBTOmFnAnbFvg+D3/J/0DpZ50FA==\n",
    "\"\"\",\n",
    "    \"delivery_h2.csv\": \"\"\"\n",
    "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\n",
    "naaj+MU1CkvpmHz/Jt2b5XRKbZy+r1n/B5CuGbA=\n",
    "\"\"\",\n",
    "    \"station_center.csv\": \"\"\"\n",
    "eNotkEFqRDEMQ/c+i2Jix4mT4xR+oX8xpYspZW5flMkqPCTLcq6PF56fjx98/T7u636+8Hd/X2IoXRumw3SJH4pNjZTIhGtIHGow\n",
    "DelABrouGShNO9K3K1FCG2eGhkzOdEyDa8oiBXLtBKsoqcbAqk3MmDOYOnWKOXESqy6xdtS+t9gu4xgNTVOso/g7qmkVG6dERxDz\n",
    "zOa+yibNwZODixYqS50PqKjqSD5T3N5EbYg7ivGaCqfYUKoOjA5TFw+q2xxq4p2qsQP7+4DTm+j0JsVF79AmPlG1YvZ9zD89y1E+\n",
    "\"\"\",\n",
    "    \"station_airport.csv\": \"\"\"\n",
    "eNo1kEFOBDEMBO9+S8eK7ThxnoM0SMxhEYdFaH+PegaO5SSVbh9vLzzfH1/4+H6cx/l84ef8PMTQpg5UwTXFSYlKDE0JUqAmproM\n",
    "tFLDWggdkjeVITRkoqUWypE6Zf1RR2qXoqVQC6kmG22R+M7FOnFibQwdYgY6QruYg1PnNPBvtkHZxkq4LrFECw1mStom2tB1lwmx\n",
    "RZxs43xbjGX3ByG2gVVXQu9ors6ToVvc0Lom1oTz1KkxbLusHrycjDC5sQEWZRpPNOMu49qQT3RWMww18UXp5peXtG7cflX8BZMj\n",
    "TyQ=\n",
    "\"\"\",\n",
    "    \"station_forest.csv\": \"\"\"\n",
    "eNotkEtqg0EMg/c+i8b4MQ/PcQp/oVmkdJFScvuiTJYfsmXJ18cTj8/7D75+77fr9nji7/Z9iaNNXdiBriaBVodCTRJta6A2lk7p\n",
    "JEclXFMGyUimSyba0kFKnbLetJCaUvRMuriGbFJgO0yHuJ2DNRHq4q80RdW1xINGTqPXcB61JpLYj4rQJT7Qhk6Ohqb4fNfyVwRf\n",
    "xHE2Xby42VnTdYtvtGAIQ9cpYWjJarTaEv5GOx8KHjJsg2lJJFrnjxxdl0Sn6qhCUB3cHaiO0JCYaKaFVcd58e6kc2iXKA7nSRXy\n",
    "D7SAUsQ=\n",
    "\"\"\",\n",
    "}\n",
    "os.makedirs(\"data\", exist_ok=True)\n",
    "for _name, _packed in _DATA.items():\n",
    "    with open(os.path.join(\"data\", _name), \"wb\") as _f:\n",
    "        _f.write(zlib.decompress(base64.b64decode(_packed)))\n",
    "print(\"Файлы данных:\", \", \".join(sorted(_DATA)))\n",
    "\n",
    "# ─── Проверка упражнений: тот же раннер, что у задач на сайте ───\n",
    "# ─── Запуск тестов ───────────────────────────────────────────────────────────\n",
    "\n",
    "_TIMEOUT = 2.0  # секунд на тест; отдельный тест может задать свой: test_x.timeout = 10\n",
    "\n",
    "\n",
    "def _call_with_timeout(target, seconds: float) -> bool:\n",
    "    \"\"\"Запускает target() в отдельном потоке. Возвращает False, если за seconds он не завершился.\"\"\"\n",
    "    import sys\n",
    "    import threading\n",
    "\n",
    "    if sys.platform == \"emscripten\":  # Python в браузере: потоков нет, таймаут обеспечивает страница\n",
    "        target()\n",
    "        return True\n",
    "    # Большой стек нужен, чтобы глубокая рекурсия давала RecursionError, а не аварийное завершение Python.\n",
    "    for megabytes in (256, 64, 0):\n",
    "        try:\n",
    "            threading.stack_size(megabytes * 1024 * 1024)\n",
    "            worker = threading.Thread(target=target, daemon=True)\n",
    "            worker.start()\n",
    "        except (ValueError, RuntimeError, MemoryError):\n",
    "            continue\n",
    "        worker.join(seconds)\n",
    "        return not worker.is_alive()\n",
    "    target()  # потоки недоступны — запускаем без таймаута\n",
    "    return True\n",
    "\n",
    "\n",
    "def _test_info(fn) -> dict:\n",
    "    \"\"\"Описание теста, его лимит времени и сообщение на случай зависания.\"\"\"\n",
    "    limit = getattr(fn, \"timeout\", _TIMEOUT)\n",
    "    hint = getattr(fn, \"timeout_hint\", \"\")\n",
    "    message = (\n",
    "        f\"тест не завершился за {limit:g} с — возможно, бесконечный цикл или рекурсия: \"\n",
    "        \"проверьте, что границы/аргументы изменяются на каждом шаге\" + (f\". {hint}\" if hint else \"\")\n",
    "    )\n",
    "    return {\"title\": (fn.__doc__ or fn.__name__).strip(), \"timeout\": limit, \"timeout_message\": message}\n",
    "\n",
    "\n",
    "def _error_result(title: str, error: BaseException, user_file: str | None) -> dict:\n",
    "    \"\"\"Результат упавшего теста: статус, сообщение, тип исключения и строка в коде решения.\"\"\"\n",
    "    line = None\n",
    "    if user_file:  # самый глубокий кадр в коде решения; ошибка внутри самих тестов — без номера строки\n",
    "        tb = error.__traceback__\n",
    "        while tb is not None:\n",
    "            if tb.tb_frame.f_code.co_filename == user_file:\n",
    "                line = tb.tb_lineno\n",
    "            tb = tb.tb_next\n",
    "    status, message = \"error\", f\"{type(error).__name__}: {error}\"\n",
    "    if isinstance(error, NotImplementedError):\n",
    "        status, message, line = \"not_written\", \"функция ещё не написана\", None\n",
    "    elif isinstance(error, AssertionError):\n",
    "        status, message = \"failed\", str(error) or \"проверка не прошла\"\n",
    "    elif isinstance(error, RecursionError):\n",
    "        message = \"RecursionError: слишком глубокая рекурсия — проверьте базовый случай и то, что задача уменьшается\"\n",
    "    elif isinstance(error, MemoryError) or (isinstance(error, OverflowError) and \"index-sized\" in str(error)):\n",
    "        message = f\"{type(error).__name__}: не хватило памяти — возможно, создаётся слишком большая структура данных\"\n",
    "    return {\"title\": title, \"status\": status, \"message\": message, \"error_type\": type(error).__name__, \"line\": line}\n",
    "\n",
    "\n",
    "def _run_tests(verbose: bool = True, on_start=None, on_result=None, user_file: str | None = None) -> list[dict]:\n",
    "    \"\"\"Запускает тесты задачи (_TESTS — функции из tests.py) по порядку, каждый — с таймаутом.\n",
    "\n",
    "    Возвращает список результатов: {\"title\", \"status\", \"message\", \"error_type\", \"line\"}, где status —\n",
    "    passed · failed · error · not_written · timeout · not_run. Если тест завис, при verbose=True печатает\n",
    "    итог и завершает процесс: зависший поток остановить нельзя. on_start(i) и on_result(i, результат)\n",
    "    вызываются по мере выполнения; user_file — имя файла с кодом решения для номеров строк в ошибках.\n",
    "    \"\"\"\n",
    "    import os\n",
    "    import sys\n",
    "\n",
    "    if verbose:\n",
    "        try:  # ✓ и кириллица в консоли Windows со старой кодировкой\n",
    "            sys.stdout.reconfigure(encoding=\"utf-8\")\n",
    "        except Exception:  # noqa: BLE001 — reconfigure может не быть (перенаправленный вывод, браузер)\n",
    "            pass\n",
    "\n",
    "    tests = list(_TESTS)\n",
    "    results: list[dict] = []\n",
    "    not_written = hung = False\n",
    "    for index, fn in enumerate(tests):\n",
    "        info = _test_info(fn)\n",
    "        outcome: dict = {}\n",
    "\n",
    "        def target(fn=fn, outcome=outcome):\n",
    "            try:\n",
    "                fn()\n",
    "            except BaseException as e:\n",
    "                outcome[\"error\"] = e\n",
    "\n",
    "        if on_start:\n",
    "            on_start(index)\n",
    "        if not _call_with_timeout(target, info[\"timeout\"]):\n",
    "            results.append({\"title\": info[\"title\"], \"status\": \"timeout\", \"message\": info[\"timeout_message\"], \"error_type\": None, \"line\": None})\n",
    "            for other in tests[index + 1:]:\n",
    "                results.append({\"title\": _test_info(other)[\"title\"], \"status\": \"not_run\", \"message\": \"не запускался: предыдущий тест завис\", \"error_type\": None, \"line\": None})\n",
    "            hung = True\n",
    "            break\n",
    "        error = outcome.get(\"error\")\n",
    "        if error is None:\n",
    "            result = {\"title\": info[\"title\"], \"status\": \"passed\", \"message\": \"\", \"error_type\": None, \"line\": None}\n",
    "        else:\n",
    "            result = _error_result(info[\"title\"], error, user_file)\n",
    "            not_written = not_written or result[\"status\"] == \"not_written\"\n",
    "        results.append(result)\n",
    "        if on_result:\n",
    "            on_result(index, result)\n",
    "\n",
    "    if not_written:  # пока функция не написана, «даром» пройденные тесты не засчитываются\n",
    "        for r in results:\n",
    "            if r[\"status\"] == \"passed\":\n",
    "                r.update(status=\"not_written\", message=\"не засчитан, пока функция не написана\")\n",
    "    for r in results:\n",
    "        if len(r[\"message\"]) > 300:\n",
    "            r[\"message\"] = r[\"message\"][:300] + \"…\"\n",
    "\n",
    "    if verbose:\n",
    "        if not_written:\n",
    "            print(\"Функция ещё не написана — замените raise NotImplementedError своим кодом.\\n\")\n",
    "        for r in results:\n",
    "            mark = {\"passed\": \"✓\", \"not_run\": \"–\"}.get(r[\"status\"], \"✗\")\n",
    "            print(f\"{mark} {r['title']}\")\n",
    "            if r[\"message\"]:\n",
    "                print(f\"    {r['message']}\")\n",
    "        passed = sum(r[\"status\"] == \"passed\" for r in results)\n",
    "        print(\"─\" * 40)\n",
    "        print(f\"Прошло {passed} из {len(results)}\" + (\" — всё верно!\" if passed == len(results) else \"\"))\n",
    "        if hung:\n",
    "            print(\"Выполнение остановлено: один из тестов завис.\")\n",
    "            sys.stdout.flush()\n",
    "            os._exit(1)\n",
    "    return results\n",
    "\n",
    "\n",
    "def _check(*tests, names=()):\n",
    "    \"\"\"Запускает проверки упражнения и печатает ✓ / ✗ и итог — как на сайте.\"\"\"\n",
    "    for name in names:\n",
    "        if \".\" in name:  # «Класс.метод» из заготовки: класс должен быть объявлен\n",
    "            if not isinstance(globals().get(name.split(\".\")[0]), type):\n",
    "                print(f\"✗ класс {name.split('.')[0]} не объявлен — выполните ячейку упражнения\")\n",
    "                return\n",
    "            continue\n",
    "        if name not in globals():\n",
    "            print(f\"✗ переменная {name} не создана — выполните ячейку упражнения\")\n",
    "            return\n",
    "        if globals()[name] is Ellipsis:\n",
    "            print(f\"✗ {name} пока равна ... — замените многоточие своим кодом\")\n",
    "            return\n",
    "    globals()[\"_TESTS\"] = list(tests)\n",
    "    _run_tests()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "setup"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Данные часто приходят частями: выгрузка за январь, выгрузка за февраль; файл по каждому филиалу. Столбцы\n",
    "у частей одинаковые, и перед анализом их нужно собрать в одну таблицу. Служба доставки «Кофейной лавки»\n",
    "присылает отчёт по полугодиям:"
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Два файла с одинаковыми столбцами\n",
    "import pandas as pd\n",
    "\n",
    "h1 = pd.read_csv(\"data/delivery_h1.csv\")\n",
    "h2 = pd.read_csv(\"data/delivery_h2.csv\")\n",
    "print(h1.shape, h2.shape)\n",
    "h2.head(3)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "two-files"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## concat: друг под другом\n",
    "\n",
    "Функция `pd.concat` получает **список** таблиц и ставит их одну под другой:"
   ],
   "id": "text-16"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# 816 + 760 = 1576 строк\n",
    "both = pd.concat([h1, h2])\n",
    "print(both.shape)\n",
    "both.tail(3)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "concat"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Строк стало 1576 — но посмотрите на индекс: последняя метка 759, а не 1575. `concat` сохраняет метки каждой\n",
    "части. У обеих таблиц они шли с нуля, поэтому теперь метки **повторяются**:"
   ],
   "id": "text-22"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Две строки с меткой 5\n",
    "both.loc[5]"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "index-trap"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`loc[5]` вернул две строки — по одной из каждого полугодия. Это почти никогда не то, что нужно. Параметр\n",
    "`ignore_index=True` нумерует строки заново. На двух маленьких таблицах:"
   ],
   "id": "text-27"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Метки подряд: 0, 1, 2, 3\n",
    "a = pd.DataFrame({\"x\": [1, 2]})\n",
    "b = pd.DataFrame({\"x\": [3, 4]})\n",
    "pd.concat([a, b], ignore_index=True)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "ignore"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Типичная ошибка: повторяющиеся метки после concat.**\n",
    ">\n",
    "> Склейка без `ignore_index=True` не даёт ошибки, но оставляет мину: `loc` по метке возвращает несколько строк\n",
    "> вместо одной. Если метки частей — просто номера строк, всегда пишите `ignore_index=True`."
   ],
   "id": "component-33"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Вопрос.** Те же таблицы `a` и `b`, но без `ignore_index`. Что выведет `print(pd.concat([a, b]).index.tolist())`?\n",
    "\n",
    "- `[0, 1, 2, 3]`\n",
    "- `[0, 1, 0, 1]`\n",
    "- `[1, 2, 3, 4]`\n",
    "\n",
    "<details><summary>Ответ</summary>\n",
    "\n",
    "`[0, 1, 0, 1]`\n",
    "\n",
    "`concat` сохраняет метки каждой таблицы: у `a` это 0 и 1, у `b` — тоже 0 и 1. Метки подряд `[0, 1, 2, 3]` получились бы с `ignore_index=True`.\n",
    "\n",
    "</details>"
   ],
   "id": "quiz-38"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Доставка за год\n",
    "\n",
    "Склейте `h1` и `h2` (в этом порядке) в таблицу `year` с индексом от нуля подряд. В `n_orders` сохраните число\n",
    "строк, в `avg_rating` — среднюю оценку за год.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`pd.concat([h1, h2], ignore_index=True)` — таблицы передают списком.\n",
    "\n",
    "</details>"
   ],
   "id": "year-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "year = ...\n",
    "n_orders = ...\n",
    "avg_rating = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "year"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_year():\n",
    "    \"year — доставка за весь год, индекс подряд\"\n",
    "    assert isinstance(year, pd.DataFrame), f\"year — это {type(year).__name__}, а нужна таблица\"\n",
    "    assert len(year) == 1576, f\"в year {len(year)} строк: нужны строки обеих таблиц\"\n",
    "    assert list(year.columns) == [\"order_id\", \"days\", \"rating\"], f\"столбцы сейчас {list(year.columns)}, а должны остаться order_id, days, rating\"\n",
    "    assert year[\"order_id\"].iloc[0] == 10001, \"сначала должно идти первое полугодие\"\n",
    "    assert year.index.is_unique, \"метки индекса повторяются: вспомните параметр ignore_index\"\n",
    "    assert list(year.index[-2:]) == [1574, 1575], \"индекс должен идти от 0 до 1575 подряд\"\n",
    "\n",
    "\n",
    "def test_numbers():\n",
    "    \"n_orders и avg_rating — число заказов и средняя оценка за год\"\n",
    "    assert n_orders == 1576, f\"n_orders = {n_orders!r} — это не число заказов за год\"\n",
    "    assert abs(avg_rating - 3.650390625) < 1e-9, f\"avg_rating = {avg_rating!r} — это не средняя оценка за год\"\n",
    "\n",
    "_check(test_year, test_numbers, names=[\"year\",\"n_orders\",\"avg_rating\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "year-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "year = pd.concat([h1, h2], ignore_index=True)\n",
    "n_orders = len(year)\n",
    "avg_rating = year[\"rating\"].mean()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "year-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Откуда строка\n",
    "\n",
    "После склейки уже не видно, из какого файла пришла строка. Если это важно, источник записывают в столбец\n",
    "**до** склейки — например, методом `assign`, который возвращает таблицу с новым столбцом:"
   ],
   "id": "text-54"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Пометить полугодие и склеить\n",
    "marked = pd.concat([h1.assign(half=1), h2.assign(half=2)], ignore_index=True)\n",
    "print(both.index.is_unique, marked.index.is_unique)\n",
    "marked.groupby(\"half\")[\"rating\"].mean()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "source"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Первая строка вывода — проверка меток атрибутом индекса `is_unique` («уникален ли»): у склейки без\n",
    "`ignore_index` метки повторяются (`False`), у новой — нет (`True`).\n",
    "\n",
    "## Если столбцы разные\n",
    "\n",
    "`concat` сопоставляет столбцы **по названиям**, а не по порядку. Если в одной из таблиц столбца нет, на его\n",
    "месте окажутся пропуски:"
   ],
   "id": "text-61"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# В феврале появился столбец tips\n",
    "jan = pd.DataFrame({\"city\": [\"Омск\", \"Тула\"], \"cups\": [120, 95]})\n",
    "feb = pd.DataFrame({\"city\": [\"Омск\", \"Пермь\"], \"cups\": [130, 60], \"tips\": [400, 150]})\n",
    "pd.concat([jan, feb], ignore_index=True)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "different"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Это удобно — ничего не теряется. Но если столбец в двух файлах назван по-разному (`cups` и `Cups`), pandas\n",
    "посчитает их разными столбцами, и в обоих будет половина пропусков. После склейки стоит взглянуть на\n",
    "`isna().sum()`."
   ],
   "id": "text-71"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Полугодия\n",
    "\n",
    "По таблице `marked` соберите `by_half` — таблицу по полугодиям (полугодие в индексе) со столбцами: `orders` —\n",
    "число заказов, `days` — средний срок доставки, `no_rating` — число заказов без оценки. В `slower_half`\n",
    "сохраните номер полугодия, в котором везли дольше. В `by_half` должно быть ровно три столбца.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Именованная агрегация: `orders=(\"order_id\", \"size\")`, `days=(\"days\", \"mean\")`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Без оценки — число строк минус число непустых оценок. Можно сначала посчитать `(\"rating\", \"count\")`, а потом вычесть этот столбец из `orders`.\n",
    "\n",
    "</details>"
   ],
   "id": "halves-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "by_half = ...\n",
    "slower_half = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "halves"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_by_half():\n",
    "    \"by_half — заказы, средний срок и число заказов без оценки по полугодиям\"\n",
    "    assert isinstance(by_half, pd.DataFrame), f\"by_half — это {type(by_half).__name__}, а нужна таблица\"\n",
    "    assert list(by_half.index) == [1, 2], f\"в индексе сейчас {list(by_half.index)}, а должны быть полугодия 1 и 2\"\n",
    "    assert list(by_half.columns) == [\"orders\", \"days\", \"no_rating\"], f\"столбцы сейчас {list(by_half.columns)}, а нужны orders, days, no_rating\"\n",
    "    assert by_half[\"orders\"].tolist() == [816, 760], \"orders не тот: нужно число строк в полугодии\"\n",
    "    assert abs(by_half.loc[1, \"days\"] - 4.175641) < 1e-5 and abs(by_half.loc[2, \"days\"] - 4.419973) < 1e-5, \"days не тот: нужен средний срок доставки\"\n",
    "    assert by_half[\"no_rating\"].tolist() != [529, 495], \"в no_rating — число заказов С оценкой; без оценки — число строк минус число оценок\"\n",
    "    assert by_half[\"no_rating\"].tolist() == [287, 265], \"no_rating не тот: нужно число заказов без оценки\"\n",
    "\n",
    "\n",
    "def test_slower():\n",
    "    \"slower_half — полугодие с более долгой доставкой\"\n",
    "    assert slower_half == 2, f\"slower_half = {slower_half!r} — это не полугодие с более долгой доставкой\"\n",
    "\n",
    "_check(test_by_half, test_slower, names=[\"by_half\",\"slower_half\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "halves-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "by_half = marked.groupby(\"half\").agg(orders=(\"order_id\", \"size\"), days=(\"days\", \"mean\"), no_rating=(\"rating\", \"count\"))\n",
    "by_half[\"no_rating\"] = by_half[\"orders\"] - by_half[\"no_rating\"]\n",
    "slower_half = by_half[\"days\"].idxmax()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "halves-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Много файлов\n",
    "\n",
    "Когда файлов больше двух, таблицы собирают в список циклом — а потом склеивают один раз. Три метеостанции,\n",
    "три файла:"
   ],
   "id": "text-84"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Прочитать три файла в список\n",
    "parts = []\n",
    "for name in [\"center\", \"airport\", \"forest\"]:\n",
    "    part = pd.read_csv(\"data/station_\" + name + \".csv\")\n",
    "    part[\"station\"] = name\n",
    "    parts.append(part)\n",
    "print(len(parts), parts[0].shape)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "loop"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Три станции\n",
    "\n",
    "В списке `parts` — три таблицы, в каждой есть столбец `station`. Склейте их в таблицу `stations` с индексом\n",
    "от нуля. В `station_temp` сохраните среднюю температуру (`temp`) по станциям, округлённую до двух знаков.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`pd.concat` принимает готовый список: `pd.concat(parts, ignore_index=True)`.\n",
    "\n",
    "</details>"
   ],
   "id": "stations-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "stations = ...\n",
    "station_temp = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "stations"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_stations():\n",
    "    \"stations — три станции одной таблицей\"\n",
    "    assert isinstance(stations, pd.DataFrame), f\"stations — это {type(stations).__name__}, а нужна таблица\"\n",
    "    assert stations.shape == (84, 5), f\"у stations размер {stations.shape}, а нужно 84 строки (3 станции × 28 дней) и 5 столбцов\"\n",
    "    assert stations[\"station\"].value_counts().to_dict() == {\"center\": 28, \"airport\": 28, \"forest\": 28}, \"в stations должно быть по 28 строк каждой станции\"\n",
    "    assert stations.index.is_unique and list(stations.index[-1:]) == [83], \"индекс должен идти от 0 до 83 подряд\"\n",
    "\n",
    "\n",
    "def test_temp():\n",
    "    \"station_temp — средняя температура по станциям, два знака\"\n",
    "    assert isinstance(station_temp, pd.Series) and len(station_temp) == 3, \"station_temp — Series по трём станциям\"\n",
    "    assert station_temp[\"center\"] == -2.86 and station_temp[\"forest\"] == -6.08, \"средние должны быть округлены до двух знаков\"\n",
    "\n",
    "_check(test_stations, test_temp, names=[\"stations\",\"station_temp\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "stations-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "stations = pd.concat(parts, ignore_index=True)\n",
    "station_temp = stations.groupby(\"station\")[\"temp\"].mean().round(2)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "stations-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Не склеивайте внутри цикла.** Писать `result = pd.concat([result, part])` на каждом шаге цикла — плохая привычка: каждый раз таблица\n",
    "> копируется целиком, и на сотне файлов это заметно медленно. Соберите части в список и вызовите `concat`\n",
    "> один раз."
   ],
   "id": "component-99"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Склейка столбцами\n",
    "\n",
    "С параметром `axis=1` таблицы ставятся не друг под другом, а **рядом**:"
   ],
   "id": "text-104"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Рядом — по меткам строк\n",
    "left = pd.DataFrame({\"city\": [\"Омск\", \"Тула\"], \"cups\": [120, 95]})\n",
    "right = pd.DataFrame({\"manager\": [\"Анна\", \"Олег\"]})\n",
    "pd.concat([left, right], axis=1)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "side"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Осторожно: строки сопоставляются не по порядку, а **по меткам индекса**. Возьмём из `left` только город,\n",
    "где продано больше ста чашек, и приставим к нему последнюю строку `right`:"
   ],
   "id": "text-110"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# По одной строке с каждой стороны — а в результате две\n",
    "big = left[left[\"cups\"] > 100]\n",
    "print(big)\n",
    "pd.concat([big, right.tail(1)], axis=1)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "side-trap"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В обеих таблицах по одной строке, но метки у них разные: 0 и 1. Поэтому строки не встали рядом: получились\n",
    "две строки, в каждой из которых половина ячеек — `NaN`. А если бы метки случайно совпали — например, обе\n",
    "таблицы прочитаны из разных файлов и пронумерованы с нуля, — в одну строку склеились бы данные, которые\n",
    "никак друг с другом не связаны.\n",
    "\n",
    "Поэтому таблицы с общим признаком соединяют не через `concat`, а по ключу — методом `merge`. О нём следующий\n",
    "урок.\n",
    "\n",
    "## Итоги\n",
    "\n",
    "- `pd.concat([a, b], ignore_index=True)` — таблицы друг под другом, индекс заново.\n",
    "- Столбцы сопоставляются по названиям; чего нет — заполняется `NaN`.\n",
    "- Источник строки записывайте в столбец до склейки.\n",
    "- Много файлов: собрать список, склеить один раз.\n",
    "- `axis=1` ставит таблицы рядом по меткам индекса; для соединения по смыслу нужен `merge`."
   ],
   "id": "text-115"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/concat](https://ds-edu-trainer.vercel.app/reference/pandas/concat) · [pandas/alignment](https://ds-edu-trainer.vercel.app/reference/pandas/alignment)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
