{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Замены и правила\n",
    "\n",
    "Значение по словарю — map, замена отдельных значений — replace, столбец по условию — np.where, своё правило — apply. И почему сначала стоит поискать готовую операцию.\n",
    "\n",
    "Курс pandas · урок `pd-map-apply` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/map-apply).\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",
    "    \"products.csv\": \"\"\"\n",
    "eNp9k01qG0EQhfc6RR+gDNU9P5q5hW9gjCyCF4mM4iyym7EMCligeBUwgRDH4GwCI1tjj35mfIVXNwrV4/hPIsvu+V6/qlc1R8PB\n",
    "wafe8d7hAX3Yf9+n3v5x/91g+JmOhoe9PvUGH487u2wJfyTHg2QoJZcczY7kWPvDxFiDJW4JF2jkFCXZMGJKutzZZedvsZIR1pih\n",
    "kqlxEZuXdJwyBYnCgbqcokKDh61oN2UKA0VDwrlkKHCPCisPb1ThUqaufzh6ujWYocS9ov6MCjWKTSPHFFqVxoRrUatazrAwMkaB\n",
    "hbHc8td6oiBispHSXcI3lFjJ+X/5lMn6eBLCLx9O/QaJQiYXK5ISLn2nNzJFjUYfzTFDI5mJXkpczGSZO7uWCb/RaItYocDc4FZx\n",
    "mWCJCgvCz/YejeRygopswpSq0hJ+oJQxStQy0bga3Ej+6FxuUUZM2ol1hO8+zDkKrGRicGPky8sqtskD95iEDXxyOn3JNjln//UW\n",
    "Ei4kkxHusERhAo1gjZXWrfBI/SlKmZwOxEaEK11a1DLeed7f17hNUqbU87GmPZLMP64LjrkuiExfK1zITDbwFXUfB4AaFZYG9zor\n",
    "FJKhkXG7Bm/sdF/iULXJ08ph7XNSW21Pla3tVywlb6uWkT4rZxobk029fUq40n9AJnKiHw1m/mcrcOfNy+1PuLAduWPCJUrJfAHL\n",
    "52i3y6wOPIq58xfQUEqA\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",
    "«категории „Кофе“ — полка A, сладостям — полка B», «дороже 1000 ₽ — дорогой товар», «доставка зависит от цены\n",
    "ступеньками». Для них есть четыре инструмента — от самого простого к самому общему.\n",
    "\n",
    "## map: значение по словарю\n",
    "\n",
    "Соответствие «было → стало» удобно записать словарём. Метод `map` заменяет каждое значение столбца тем, что\n",
    "лежит в словаре по этому ключу:"
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Категория → полка на складе\n",
    "import pandas as pd\n",
    "\n",
    "products = pd.read_csv(\"data/products.csv\")\n",
    "shelf = {\"Кофе\": \"A\", \"Чай\": \"A\", \"Сладости\": \"B\", \"Посуда\": \"C\", \"Аксессуары\": \"C\"}\n",
    "products[\"shelf\"] = products[\"category\"].map(shelf)\n",
    "products[[\"name\", \"category\", \"shelf\"]].head(7)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "map"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`map` пропускает через словарь **все** значения. Если для значения нет ключа, на его месте окажется `NaN` —\n",
    "так pandas обозначает пропуск, «нет данных»:"
   ],
   "id": "text-21"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# В словаре только кофе и чай\n",
    "partial = products[\"category\"].map({\"Кофе\": \"A\", \"Чай\": \"A\"})\n",
    "print(partial.tail(3))\n",
    "print(partial.notna().all())\n",
    "print(products[\"shelf\"].notna().all())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "map-missing"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Пропускам посвящён первый урок следующего модуля. Пока достаточно знать: `NaN` после `map` значит, что\n",
    "в словаре не хватило ключа. Проверить столбец на пропуски помогает `notna()` — маска «значение есть».\n",
    "`столбец.notna().all()` вернёт `True`, только если пропусков нет: в неполном переводе они есть, а в столбце\n",
    "`shelf` из первого примера — нет."
   ],
   "id": "text-26"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Скидка по категории\n",
    "\n",
    "В словаре `rates` — скидка для каждой категории. Добавьте в таблицу `goods` столбец `discount` — скидку\n",
    "товара по его категории — и столбец `sale_price`: цену со скидкой, округлённую до целых рублей.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`goods[\"discount\"] = goods[\"category\"].map(rates)`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Цена со скидкой — `goods[\"price\"] * (1 - goods[\"discount\"])`; всё выражение в скобки и `.round()`.\n",
    "\n",
    "</details>"
   ],
   "id": "discount-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "goods = pd.read_csv(\"data/products.csv\")\n",
    "rates = {\"Кофе\": 0.04, \"Чай\": 0.06, \"Сладости\": 0.12, \"Посуда\": 0.18, \"Аксессуары\": 0.18}\n",
    "# добавьте в goods столбцы discount и sale_price"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "discount"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_discount():\n",
    "    \"discount — скидка категории\"\n",
    "    assert \"discount\" in goods.columns, \"в goods нет столбца discount\"\n",
    "    assert goods[\"discount\"].notna().all(), \"в discount есть пропуски: словарь должен содержать все пять категорий\"\n",
    "    assert goods[\"discount\"].tolist()[:6] == [0.04, 0.04, 0.04, 0.04, 0.04, 0.06], \"скидки не те: применять словарь нужно к столбцу category\"\n",
    "    assert abs(goods[\"discount\"].sum() - 2.18) < 1e-9, \"скидки не те: применять словарь нужно к столбцу category\"\n",
    "\n",
    "\n",
    "def test_price():\n",
    "    \"sale_price — цена со скидкой, округлённая до рублей\"\n",
    "    assert \"sale_price\" in goods.columns, \"в goods нет столбца sale_price\"\n",
    "    assert abs(goods.loc[0, \"sale_price\"] - 58) > 1e-6, \"в sale_price — размер скидки, а нужна цена со скидкой\"\n",
    "    assert abs(goods.loc[1, \"sale_price\"] - 662.4) > 1e-6, \"значения не округлены до рублей\"\n",
    "    assert goods.loc[0, \"sale_price\"] == 1392, f\"sale_price в первой строке — {goods.loc[0, 'sale_price']}, а должна быть 1392\"\n",
    "    assert goods[\"sale_price\"].sum() == 15819, \"не все цены верны: проверьте формулу и округление\"\n",
    "\n",
    "_check(test_discount, test_price)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "discount-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "goods = pd.read_csv(\"data/products.csv\")\n",
    "rates = {\"Кофе\": 0.04, \"Чай\": 0.06, \"Сладости\": 0.12, \"Посуда\": 0.18, \"Аксессуары\": 0.18}\n",
    "goods[\"discount\"] = goods[\"category\"].map(rates)\n",
    "goods[\"sale_price\"] = (goods[\"price\"] * (1 - goods[\"discount\"])).round()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "discount-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## replace: заменить только некоторые\n",
    "\n",
    "Когда менять нужно лишь часть значений, а остальные оставить, подходит `replace`. Он тоже принимает словарь,\n",
    "но значения, которых в словаре нет, **не трогает**:"
   ],
   "id": "text-39"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Посуда и аксессуары — в одну категорию\n",
    "products[\"category\"].replace({\"Посуда\": \"Для дома\", \"Аксессуары\": \"Для дома\"}).value_counts()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "replace"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Запомнить разницу просто: `map` — «перевести всё по словарю», `replace` — «исправить указанное»."
   ],
   "id": "text-46"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Вопрос.** Сколько непустых значений останется:\n",
    "`print(pd.Series([\"да\", \"нет\", \"да\"]).map({\"да\": 1}).notna().sum())`?\n",
    "\n",
    "- `3`\n",
    "- `2`\n",
    "- `0`\n",
    "\n",
    "<details><summary>Ответ</summary>\n",
    "\n",
    "`2`\n",
    "\n",
    "Для «нет» ключа в словаре нет — `map` поставит на его место `NaN`. Останется два значения. `replace` с тем же словарём оставил бы «нет» как есть.\n",
    "\n",
    "</details>"
   ],
   "id": "quiz-49"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Напитки\n",
    "\n",
    "Добавьте в `goods` столбец `group`: категории «Кофе» и «Чай» заменены на «Напитки», остальные — без изменений.\n",
    "Столбец `category` не меняйте. В `n_drinks` сохраните число товаров-напитков.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`goods[\"category\"].replace({\"Кофе\": \"Напитки\", \"Чай\": \"Напитки\"})`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Число — сумма маски `goods[\"group\"] == \"Напитки\"`.\n",
    "\n",
    "</details>"
   ],
   "id": "groups-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# добавьте в goods столбец group\n",
    "n_drinks = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "groups"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_group():\n",
    "    \"group — кофе и чай объединены в «Напитки»\"\n",
    "    assert \"group\" in goods.columns, \"в goods нет столбца group\"\n",
    "    assert goods[\"group\"].notna().all(), \"в group есть пропуски: похоже, использован map — он превращает в пропуск всё, чего нет в словаре. Нужен replace\"\n",
    "    assert \"Кофе\" not in goods[\"group\"].tolist() and \"Чай\" not in goods[\"group\"].tolist(), \"в group остались «Кофе» или «Чай»: оба нужно заменить на «Напитки»\"\n",
    "    assert sorted(goods[\"group\"].unique()) == [\"Аксессуары\", \"Напитки\", \"Посуда\", \"Сладости\"], f\"в group сейчас значения {sorted(goods['group'].unique())}\"\n",
    "    assert goods[\"category\"].tolist()[0] == \"Кофе\", \"столбец category менять не нужно: результат replace запишите в новый столбец group\"\n",
    "\n",
    "\n",
    "def test_count():\n",
    "    \"n_drinks — сколько товаров-напитков\"\n",
    "    assert n_drinks == 9, f\"n_drinks = {n_drinks!r} — это не число товаров-напитков\"\n",
    "\n",
    "_check(test_group, test_count, names=[\"n_drinks\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "groups-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "goods[\"group\"] = goods[\"category\"].replace({\"Кофе\": \"Напитки\", \"Чай\": \"Напитки\"})\n",
    "n_drinks = (goods[\"group\"] == \"Напитки\").sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "groups-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## np.where: одно из двух по условию\n",
    "\n",
    "Столбец «если условие выполнено — одно значение, иначе — другое» строит функция `where` из библиотеки\n",
    "NumPy. pandas построен на NumPy, и некоторые её функции удобно вызывать напрямую; подключают её под именем `np`.\n",
    "У `np.where` три аргумента: маска, значение для `True`, значение для `False`."
   ],
   "id": "text-67"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Дороже 1000 ₽ или нет\n",
    "import numpy as np\n",
    "\n",
    "np.where(products[\"price\"] >= 1000, \"дорогой\", \"обычный\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "where"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Результат — **массив** NumPy (`array`): набор значений без индекса и названия. `dtype='<U7'` — так NumPy\n",
    "обозначает текст длиной до семи символов. Массив можно сразу записать в столбец таблицы:"
   ],
   "id": "text-75"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Массив становится столбцом\n",
    "products[\"segment\"] = np.where(products[\"price\"] >= 1000, \"дорогой\", \"обычный\")\n",
    "products[\"segment\"].value_counts()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "where-column"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Высокая и обычная маржа\n",
    "\n",
    "В `share` — доля маржи в цене каждого товара. Добавьте в `goods` столбец `margin`: «высокая», если доля\n",
    "не меньше 0.5, и «обычная» в остальных случаях. В `n_high` сохраните число товаров с высокой маржой.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`np.where(share >= 0.5, \"высокая\", \"обычная\")` — сначала значение для `True`.\n",
    "\n",
    "</details>"
   ],
   "id": "margin-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "share = (goods[\"price\"] - goods[\"cost\"]) / goods[\"price\"]\n",
    "# добавьте в goods столбец margin\n",
    "n_high = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "margin"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_margin():\n",
    "    \"margin — «высокая» при доле от 0.5, иначе «обычная»\"\n",
    "    assert \"margin\" in goods.columns, \"в goods нет столбца margin\"\n",
    "    assert sorted(goods[\"margin\"].unique()) == [\"высокая\", \"обычная\"], f\"в margin сейчас значения {sorted(map(str, goods['margin'].unique()))}, а нужны «высокая» и «обычная»\"\n",
    "    assert goods.loc[0, \"margin\"] == \"обычная\", \"у первого товара доля 0.4 — маржа «обычная»: проверьте порядок аргументов np.where(условие, если да, если нет)\"\n",
    "    assert goods.loc[5, \"margin\"] == \"высокая\", \"у чёрного чая доля 0.57 — маржа «высокая»\"\n",
    "\n",
    "\n",
    "def test_count():\n",
    "    \"n_high — сколько товаров с высокой маржой\"\n",
    "    assert n_high != 9, \"9 — товары с обычной маржой, а нужны с высокой\"\n",
    "    assert n_high == 11, f\"n_high = {n_high!r} — это не число товаров с высокой маржой\"\n",
    "\n",
    "_check(test_margin, test_count, names=[\"n_high\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "margin-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "share = (goods[\"price\"] - goods[\"cost\"]) / goods[\"price\"]\n",
    "goods[\"margin\"] = np.where(share >= 0.5, \"высокая\", \"обычная\")\n",
    "n_high = (goods[\"margin\"] == \"высокая\").sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "margin-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## apply: своё правило\n",
    "\n",
    "Если правило сложнее — несколько ступенек, длинное условие, — его можно написать обычной функцией Python:\n",
    "на вход одно значение, на выход результат. Метод `apply` вызовет функцию для каждого значения столбца:"
   ],
   "id": "text-87"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Три уровня цены\n",
    "def level(price):\n",
    "    if price < 300:\n",
    "        return \"до 300\"\n",
    "    if price < 1000:\n",
    "        return \"300–999\"\n",
    "    return \"от 1000\"\n",
    "\n",
    "\n",
    "products[\"level\"] = products[\"price\"].apply(level)\n",
    "products[[\"name\", \"price\", \"level\"]].head(6)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "apply"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Функцию передают **без скобок** — `apply(level)`, а не `apply(level())`: мы отдаём саму функцию, а вызывать\n",
    "её будет pandas.\n",
    "\n",
    "`apply` умеет всё, но у этого есть цена: функция Python вызывается отдельно для каждой строки, и на больших\n",
    "таблицах это в десятки и сотни раз медленнее готовых операций. Поэтому сначала стоит проверить, нет ли\n",
    "операции, которая работает со столбцом целиком:"
   ],
   "id": "text-94"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Тот же результат — но формула быстрее и короче\n",
    "def plus_ten_percent(price):\n",
    "    return price * 1.1\n",
    "\n",
    "\n",
    "slow = products[\"price\"].apply(plus_ten_percent)\n",
    "fast = products[\"price\"] * 1.1\n",
    "print((slow == fast).all())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "apply-slow"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Порядок выбора: арифметика и сравнения → `map`, `replace`, `np.where` → и только если ничего не подошло,\n",
    "`apply`."
   ],
   "id": "text-103"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Стоимость доставки\n",
    "\n",
    "Доставка товара стоит: бесплатно — при цене от 1000 ₽, 150 ₽ — при цене от 500 до 999 ₽, 250 ₽ — если товар\n",
    "дешевле 500 ₽. Допишите функцию `shipping(price)`, добавьте в `goods` столбец `shipping` с её результатами\n",
    "и сохраните в `free` число товаров с бесплатной доставкой.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "В функции — два `if` с `return` и последний `return 250`, как в примере с уровнями цены.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`goods[\"shipping\"] = goods[\"price\"].apply(shipping)`; число — сумма маски `goods[\"shipping\"] == 0`.\n",
    "\n",
    "</details>"
   ],
   "id": "shipping-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "def shipping(price):\n",
    "    return ...\n",
    "\n",
    "\n",
    "# добавьте в goods столбец shipping\n",
    "free = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "shipping"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_function():\n",
    "    \"shipping(price) — стоимость доставки\"\n",
    "    assert shipping(1000) is not ..., \"функция shipping пока возвращает ... — напишите в ней правило\"\n",
    "    assert shipping(1000) == 0, f\"shipping(1000) = {shipping(1000)!r}, а от 1000 ₽ доставка бесплатная\"\n",
    "    assert shipping(999) == 150 and shipping(500) == 150, \"от 500 до 999 ₽ доставка стоит 150\"\n",
    "    assert shipping(499) == 250 and shipping(150) == 250, \"дешевле 500 ₽ доставка стоит 250\"\n",
    "\n",
    "\n",
    "def test_column():\n",
    "    \"столбец shipping и число товаров с бесплатной доставкой\"\n",
    "    assert \"shipping\" in goods.columns, \"в goods нет столбца shipping\"\n",
    "    assert goods[\"shipping\"].tolist()[:6] == [0, 150, 150, 0, 150, 250], f\"первые значения shipping — {goods['shipping'].tolist()[:6]}: примените функцию к столбцу price\"\n",
    "    assert goods[\"shipping\"].sum() == 2750, \"не все значения верны: примените функцию к столбцу price\"\n",
    "    assert free == 7, f\"free = {free!r} — это не число товаров с бесплатной доставкой\"\n",
    "\n",
    "_check(test_function, test_column, names=[\"free\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "shipping-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "def shipping(price):\n",
    "    if price >= 1000:\n",
    "        return 0\n",
    "    if price >= 500:\n",
    "        return 150\n",
    "    return 250\n",
    "\n",
    "\n",
    "goods[\"shipping\"] = goods[\"price\"].apply(shipping)\n",
    "free = (goods[\"shipping\"] == 0).sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "shipping-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итоги\n",
    "\n",
    "- `столбец.map(словарь)` — перевести все значения по словарю; нет ключа — `NaN`.\n",
    "- `столбец.replace(словарь)` — заменить только указанные значения.\n",
    "- `np.where(маска, a, b)` — `a` там, где `True`, и `b` там, где `False`.\n",
    "- `столбец.apply(функция)` — своё правило для каждого значения; медленно, поэтому в последнюю очередь."
   ],
   "id": "text-115"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/replace](https://ds-edu-trainer.vercel.app/reference/pandas/replace) · [pandas/apply](https://ds-edu-trainer.vercel.app/reference/pandas/apply) · [pandas/where-mask](https://ds-edu-trainer.vercel.app/reference/pandas/where-mask)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
