{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Ключи и ловушки\n",
    "\n",
    "Ключи с разными названиями — left_on и right_on; одноимённые столбцы — suffixes; соединение по двум ключам; главная ловушка merge — повторы ключа, которые размножают строки; проверки validate и indicator.\n",
    "\n",
    "Курс pandas · урок `pd-merge-keys` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/merge-keys).\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",
    "    \"orders.csv\": \"\"\"\n",
    "eNqVfUuOdjFu3TxrKQMiqee4N9A7CAx3AzGQtBOne+CZ7RV50oEdJ/EW/t5R8En3QfKeS1XNCoVzJF191IuHov7u73/3+7//z3/7\n",
    "u6/f/fUff//1N3/6n3/8u/+2/vHf//7vfvenv/nj58//8ae//sMf//aP//D1N//lr//wh9//1/9EKSX64sTlrxL9VaKv36RRvn5L\n",
    "/Yu/fv2fX//yl3/89b9//fkv//zrP379+68///q3v/zTGyWNL4oobCllfP2W0ofyH3/5x1//+uvff/2/X//r159//d9f//rrz2+E\n",
    "VCOCGAIn+fptki/6+ss//fqXX//2l39+BX0+1oDyDeKv36T86RH59Mhb1cUSRPZdWA2FJM0urLYhzYJa/fpt4i95b0i/CfL1m88P\n",
    "9VsKCcMS2ueXbF/1lUDJEnr6+i3VTw2vn0pkKYNnd4YUthT+/Ewl+PEhgYKfDBJy0FOkLCd//YZozK6NfmPKhvL5zeOfj4qto6VJ\n",
    "COtQdlTO72jhUKRmKdLnp9uBQt2C+hpNbQ+qX9mClInVr98wtznkop+fk6GksX7+918TEmrQ05AwAgNjsoTazznh/SsAJclXjijs\n",
    "mvUZvxR9ByAkir5DLKHlg6B/Mgiqbpbk7H5XAmYEQX6+5WJAxPlcG947Spl9+5S7HyncDGX9HC3qKkQIjQoRcmS2avz0r9/QSAfB\n",
    "9A4CNTfIeBhQmrNT9T8GAM1hpUGSbHU1T5DsQb46IQBqvjq2bZL1q0SzA6Js1hNRBj0+lHTMxKYp2YFkO8NDSoqbgig5rqUYCpW1\n",
    "3XKtr7bcvHY2tAc9frdmq6ttTkLRcESUzYZQbrOm9Fk76mEdP6TUuJZhKCxts5HIyRE+c1FzPYRA83MNiGxrZzeyM38EevxqmQ2I\n",
    "qG/XeEgpYe9msZQ5z0Qb7pxfeuFHhBrMj7nYJs0zgPguRCC/DuXb8Omz7+e6XS8gJba23Axl9vrTeADo+VHdggpNUNmDyK0MCPQ0\n",
    "w2FBmcEaA0HdTUMlGVBq/TiFvfcaolANf5tC7rdp23kXUmILKGwpUua3vG8AitgvqQ0ssAj0OSJZkBoqfOzAPntWCyoW1AfYpiEQ\n",
    "JffTlmpANCowEgR6GG5pDpTOaf2tz5R9yodQwH4DgvzZvgwLSm0zvdRkCXXvxqhkKJ8f87P3yO91AMLcAP+IEB0aK9uv+OyekzhP\n",
    "AgSNb4A4uV6uynw/h9reNtvomi2hpGO7ZkotFvQ5xj7sHYES+/ZVA6JjX+FAzZbUyjG9vn5EN4R1iA1tSxlj+dhWPc40phkI5PcR\n",
    "LRnQx53y3Gw0ZTWfI1RNm7NKY0uYq5CfzCHITwtNDGhNehwYRMuW0PJ22CEKxTugViylp42dtmoI6yf283Jrtkf4XLyDhgDK5rjS\n",
    "uuv3MXuIf0qJN+1NWV8798Z+pPRkQCQ8jytR6ztZCu32hYiwluY3AhvCmoMkqgERIjdnF/fZyEcGQX7z3rOrms7z3lvVgBB/XLHN\n",
    "GLw9HvRqKdOxXH5MWa6pd4qy/H7O1z36km4I5663RnUMQ2FCToaRbENK3rogB1lKRmduCPJL21C29zkbM21HJqTEe9UhhvKRJNYC\n",
    "YJqSLSjT1omJKLuuK7b1H2fLRzZ4/dlHtXWUdjTLtB2AHmvRaA6UN2MHEebu+JVwmyinczWO9nOQUH5IoOjUMYYhUM5g4YIgN6FR\n",
    "SgZ0u3RfqoYECnb7pJTD+XGlHxOPaQYCNQ9iVzU/j1Gk5D6eQmU/f6036yUl/jGdfTB85cWASNaEUi2o2pKkPV3tGFT8Z9xWynx6\n",
    "PCn+DERJnx3uO6VbytwU8/siCQk0ol9/GMLapXbfIwDkZwNSMuQHxOnz6/tuU8LjLCmP55yIQexBbKtj2v4ASi+c5RI4nJNSCFnO\n",
    "vWEwVZJSCD+EZS71p4RgNsGEEtVQ7TccrsZsP1SZ46Vs5mgRxJR47Co9clJoI/GQ0iZ5ngPHTn3AlHDrREps5HyKVcGagQnB8ZSU\n",
    "1DgJcwftdisQ5FdQDMoepIbD59zaOrBtBHpM0LCk4UFiQYwmUgh6tCkb0FTkoygUUtIk13NHyl/tRwSi6LeuhrAkmfJTAkXf0Cxh\n",
    "tw0npTjy5wRC7SnakhITPyBO7fDbvY8DpRqucsfuoEJKQ+R2zqJ+nkcg8lO4Ug15HhT6phcgoQUzpxIZPwRiQW1FIPFtza7qejj2\n",
    "X6suttS+YrjoR4TIy0hKOZyEVo9Qj58QKFqpYA3RXlKJjtzPo0m0W1X6IU8dte/iDCBl9dM75R4Y8tlpc3meSEkpiHLqkhSKS5CS\n",
    "QpkEU0Zcyz2W5JIwIquHhB4R2BLGxlVJSpgUOlxQ4Z7n0hl5Bvl9fpG1qzK/AAB5NYMubXGBEq81LEcdWG25NT+d1HRpgGe5shO/\n",
    "IYUk3NDDWnr883dLkR6HPtIlCh6Ew/n0TrjUPl4xjUyxs48u4Y6PaMy6HYKQEo+uS+s7KLKfGyAl9NJAymYbfMmFfAejpu621yXb\n",
    "Tv1sSknicgEl9mRTUaNBbs+k/JRCccOqoXy2iGFkJpVmCZUPD6vpIQB6+D9Kt639TN2xzAApm1m5DNeUDGaemly5a1y1LYj8xriS\n",
    "rS7LUwCmqgwzH7u5Z0kA5H2iVJW9llNvDuQgSAg3ZpAQbYBrNgSG5wcI8k6nWgzo4xd4hNlRVQb8EZJS33lvMSUeWbVZynHecU3p\n",
    "FiQZffkwoG1gJCREHkpqyX1eO+eanxAiz1JTRt50GLT+UAjyXdbYgmoGJ1II8kfpJhY0+nZqgJR4AmrZNUX2lGIpnbZHt1YtZfr0\n",
    "o1UbEdIIzgutGcLyBMQrBaTEHqKmBkQ/PCsPRwkCPdaKNgxohaTEo7wnW26SY/5865NOhrBkSxcbQ51dqW0XUEpdbNulABdrz7Zy\n",
    "zodmakDFgQRstXu1oBmk7v2wENS/UxLbsDjqyiwuycoPTgjyp7TeLei6hmJAtyHccZPRAQgSoi3zSIawJmh/pIAg71gYt0HNED6u\n",
    "wCc32III/aZDLKgmsBIi0GPCHdmAPpdgdnviUQxlOil9oBKN21boPGhE8aw0bpMgORfL6Icc3RLmPOFX1zEMaM3P3wK5X5iT+oWn\n",
    "d7s8N3IQNM/vBkQGtBZwt8ZxYlvS9Mdk3yZ5lhSvcZzU7/3ZQx3bo2LLRaDxHZB4UDGg8+Ihb0F+8HCqFtT4lLvevxVQUv24j98p\n",
    "zTVFdmdXTBnRes6p266bV27I/7jDlZsOke29XFLmV4+QmFgoYyJHabuTN6SkGvlCMCUMLWFiQ1k7bD9KSGy518XDLcjtTpmyAaXp\n",
    "5hoeVCyI5Dhd7UB+bWOqFjTazmXA1OxnXDeFTbkA5E+3TMr82n0fx33GsKDP3MKBWMOcLGH0WEphJkgI3HuQErsImJUdXd788q6r\n",
    "M4shnLdpokHEGdThJBDmYkDr1OJ/P662JDn9l0HlzVHkKb1hkJ9xuLvPGM+tG/OwnzHW0aLtQfI1DEiUwYy716JJQchQaNBzS8jC\n",
    "BnTei33/xREhcruyiCVI3/5Kku3HosAWlmLLJbClwaBHH1QLOjbYDtRcm7bXlDBFYkq3v9mnvVEIJsttPDO2KPV464gJwZaeczKE\n",
    "eQagwOfCmSxhuvr8AM9sQMsqQr2Hs9hy7yvnbw3JlkDgnMZKN5lxV3PnEG8DlG4yQ6oGCLjEIL+yKdljBWeBkxAroWOF55RdKCQr\n",
    "qWOGOxHtDiqsxI5Jqf0pCUOQP5RhkN+NK91jxu4cK409ISgNguVsUxjRyUqDmCE+0rd7UiUvrFpy7FTEhMDlx0paWHFHGzcwK2Fh\n",
    "EhI4jbLSCWYIEYrsY+X0v0HNLS7Kzc/5/u1tdcqDv4KcNlfGMSEQ7jGhRARlROW85tXDTZESBialjqdGhUGP/hALmrJH6AVk5bif\n",
    "FCngZ1WO+xmW0+gMtHsvtzrK2Bq98smv0BwC21oI6h7UbeWSdnewWXnlJ4WBwoVBfkJCoMfhULnoud7+z/fhp7zv67vB/RpWjvUZ\n",
    "ytMSOD0oL/mMI0rgihcGPT4iW9CR6CK/f0Sx7asMfjzl+7ZBS6+lNtuM0VBblUn0cxL2P53ySk8QyTl436pGhCCIhpUTe0bd1E0c\n",
    "FSRQNE8rr/dsUk5xEiNMkKiG08rkCFpo07Fh+xuBHp60y5N+gMomHpcvr/oi4G3O5VUXk8oq6AFECH+Uaptx6SDv88vlMT8otBJX\n",
    "vJsWIsSm1S2hpc2KfnnSj8/mBI40CDQvLmnQ5T2XIxqix2GbkBAeES7X+0GY01+00g82BBUr8kYQW8PYTTuQMKIasvto5GWAID87\n",
    "j2I/rmzycmBCjz6uuo8DUgePZkutY7ulH92WO+Mbyo8pm8M6rCVe/4ey87lJp3jSkJQMYV2Wcs4WDHK7Cgjyt7clkQWN7fEJUyim\n",
    "sKWgExcGdf9RanTkIwrai6SSlLHPzXI+8gFsQY8+LBbE9cgWZkDKqOc9aHkataRmQXkzjUrqhjATe5D49g0LYo4PdJgQjFhIiJxg\n",
    "Qsrs2pluJcxFiCmhY0KILIW312shhUrcMFSLxA1jQ1lXPjk6qAmJoax9gh/ypMy1H+bq1VWh4kB1l+dFqFrKvC7mFmIIStkNA1IW\n",
    "Ps7PKHHl3VDWAr/pX0DZ/STDUgTkIMUgPx1wMqDzroTtB76N5sry5BVhDPK/ObMFHdlxZAvy4SgY1H11YkDr3CXfAVXfT9mCZkKW\n",
    "8JSOKfFCxMVQ+IhRiEyBb0OeYQ1TYQ3C3IWbIVCR7WLH3VJG3anvwsM2q24jDUVuU5yBFIci9HoHSoQsga7El28ENoTVvcHuGxOi\n",
    "pULEENZGz9sSAj1WfFHWM2NuQdpLDGoeVAxoRccEHhRMyNFXV0vIIKpXRNldPm/fP0DdgJb/bPiuASDv4RMZtroK7l1ITrYk4c1e\n",
    "NpMtlYHkLZkt6Ih7jBbMLLYhnZ7JiyDIe7ZwSX6PnJXdlLMHQ+VXcnlSHr8MAj2MNlcD+oTXPLeBCDQ3HduS/GjLyu7mrnObkg9S\n",
    "4oAUyd1Qlhvw8VEIRN8BDf/lw7awbkQJKckSeo9vpGJCkCJYClmCrMDaHxKC28dS2PZMb8C6irL/76WckZINZUXE9Khviq2DQPJO\n",
    "Kco4+7cyOUtphrKGT7w0l25rSbsVqgxLGAwmEQR6DKyqLGTcUS12zqpkQCvGJHBcSmVbaklgXwdBw7dPbNUoAabU/ARF10ikFkso\n",
    "dXcbS+ptCUv7FjDhQdCjy5sBfeNms9Tuys1gswFBfu9ehwHxHUD31lWAEEUrSEu2GbLbnzWy/cE59shjQopqYPsNtLyMPehxRNmc\n",
    "/dttqjM7y+d3fRyFEeixt2zZgFY8Egd74nbb8wxQSGhSRaDHpgKW5HcIrVpQBWliIchH5WKQP0q2ZkD3RPHaHYgQzQdNDR05ry+4\n",
    "tJjShgEtr0tYKiBQ4JSXnmwz8iY7IiREUY+4hhIR1FjL37qfhintx5RYcReluM1QhYFWDwSaCQkNSAwoJXAlFIK8Wi9Kr5sZQyTF\n",
    "OY4wIZr6lHY34xemnzleO5R6N+vgfKbd+hElhen7cC0pbpgapWfSzd23dEsRAY4uCPKLpRL6Fog2qxQkBLFvokQ/rjqrum4GBLnb\n",
    "ThD08IUqyZDruU57A1Uy4YwduIP03z5CLCHxxqKVoDfDGWZK/MdXKyvuZ7b06MSu9Lkrx8nGcafUuhUBsTtcKRXtblTs6YOU2KWm\n",
    "hDdWsbiufxDIWXBWOtoEDX7uJCDIr/lZKWc8TtE7mIWyksRmopNPLmwKwsQgITqcZaWnzXZXkDQqK6lMrghWikothsB0ToWmVATK\n",
    "vl+rAS2BPhgXWelsk1DoCBV8NRVIibPpZiXOTcoRNvdTSrzRxZRQos5KrptpWWg8/YZZ6W0z2UsGiduz0r5mSR0IIhDkz/ZZSWJC\n",
    "90XzqLMghWKK2I9i2WlfWWlqdy2BGAoJUUBKVoKcXKku3S4/X+paPkJy0jNAOF/q2gJ93oh7vOoFQT51eb4UtKO6sg18yJc4dpRb\n",
    "97/gJZXlFStRgK4IQf4kly+pLKtcK6F/EVIofOQrXwrZQWEQGJwv8Svf4SuxRyFfulaeUQ3zHEqRjUFC/imB3z2D+dLNDkLaPGaQ\n",
    "uRrCcs7V+LMRheJfQBmvykZpxwor4y2nGrT5BRAlR/cSISXOQJl5WMongJjDfXmWZChn6Lv9YATyUZFZyFZetok8ISV2uGZhQ1nn\n",
    "zU0tYltP/HQ7YFDx/aCs/LPtJt5O75BCMaU4ChDLIMhf98xSLSgBnRyDHt3TDOjUqKKhhChxjrEs3VDmlBl5OCAhirXMMiyB2rZR\n",
    "WRl/Oy/3+N0MBDnfQ85kQZzj656YEMjlOavR0U/Xiv8tEeix88pqPIxzlxGGAWNKvEDnbCjntOiaAkCP8ZDv8TDjMBLIlp1zNSCq\n",
    "3/j1ESWeb3KzTTnOlu5H6Aa0crn7sxoCPVYjCHLhgTkP+xnHJfe2BxXrxM0lueokFg1zuY14PeKVT239B4TI458LG8KZD9oaSBED\n",
    "Os+97zuUkm0zEj1DV3NRZsf3rRnag+g7IL+3LtWAzj1wZI2QEm8jSrNNuRIYmaYgkHdblG5BbXtjDFPinXYZjgLcLLkmCzouQsgW\n",
    "RI+SyIJ2r1hAQiSb56oMWs5NQyAN5CqGcObUin7kmm0dkuPLN7kWQ5h3Ah5rS6221NTibGC5NltqoWc2QwwqHtRtHzQ+JqYdyMfE\n",
    "5KrsKZ/38OKDP6LEGnNuyVLq5jWf3MgQVpIN96hjbmxLReIWBD0cNE1sddK2h4mmbKq8ZJrKrRgQJX56wTGo+W+tFlQZHEsQ6LEH\n",
    "hqDi29Ts1+VtDtDclLlduWU4PPa1YSnHq4f0U0q88erJUFbuI7/x6srg5pVGed5IwyB/gOxsQEuTH6FDFFFilRBT4rNWF0tJm0zh\n",
    "mDAiQraEY551/VNcqW3rl+3KZPsdbRpSmqGsLVC0cEFCdLzq3RBWQLDLu5W7Mtdxby/ed5AjGcI6tAURU3mQJUh66lIY5Nf6wRZU\n",
    "9rsnSIkdVZASG+647XDFFbWNHY5sCde8Yj63GNDpBQkbgiixHY5qKZ8pw2eLgqBne5v9qHYdkN8rB5RYu87jtuoZodO3yfIxheOG\n",
    "IUos54xhKeNIt5N+yIlXsZKSoaxA4nBzUxJZSgcew5LYgijvzB5S4rcGS1Ijhc/rc2TnpJKyAaVadhn8SiqWIiBpSEnVgo7XdUtU\n",
    "LqDE6ddKaoZyZncpP6WEvnFIoQ1F2bToh0RfZqmShiFsX9WBhOjAUSjZJtFWycGUcPxDSjzLFCL7JeMbFLYU2moihcRRapxeqpAa\n",
    "Gfl4BS1617pQsYRG2wlGiY98PgTt95AY5LasGCS+JGXI5XQxhJ8ECFEcbVHy5k14NEOZbtWvkm9BfiJVEuYC8fHIjwEp+5qPwfZ4\n",
    "xwAJUaA1JkS2pfTOlWRlk5yiKO1z5U8BqgcEPWxACZbc7pS+7/MGJOSorYAQXVouShLV72a5dgOQV9eLEj4niLYn1aKEz/lT9AKM\n",
    "UUmSq9z+lIow6NFCZdb9XJSzdVNgUHUrt9ImZxKZ423KaO1DFAplgKKUytmUxNsVVimVK38OyM0NQd4fVZQ2Odt7ZVUMKkeU8EBZ\n",
    "lFK5cuWM54U2CPJiQ1Ha5J3XJ95MKRFyhoAx7cKIi9IXZ5hZ3z5vgClhkBWmxEusEiUnBaU5KUqInKAsm52RUhhXVN3mqUhMyBHh\n",
    "tvEV6YUWWCUerjA8BvMPAj3mCSUXrqg6kEwegrx0UpRAOIP4uD1P8kUJhJLuZNKuTRW0KXBNFKX1yZWx36UsL0qhm91x3TR8LfU0\n",
    "jrJCnY7To2srAjmfZbnUugMksj26XfLbQZmuwcDpUy75rRzvmoHUBhjkT0uXRneAGlq5L12uHJl5chzMjQnRPuaS4oqKsIrPpZCy\n",
    "6edqm9XqU20tl8p2gsr2MAgpHFP6TZmqTYovikAChb/BMIS5fsZiQqnJUiQ9U0eWSgZ0PoBhB0FlVxJvO7EqS7yeOAjUsVKzIZyP\n",
    "tdpfsxYLSh1sX2t1JYEQtFKbAfH13FTwSeo3K8fJL/KiljoM4X7UTTekJVdqf16vL41sSS1v9vlN/WJXTJLfCiPQozebGNDyMQWv\n",
    "VpaWDWFllvILXCuu6u2Lm5gSzxBNWUJ7eUWktGZAa4X0q3brFnQnk32vfNjK036v1JOhnFkU7K/WlS2ozay1qs4GtF5oiWfgrn7o\n",
    "T/SRgMxdpd8/ro0SerOGXixBwItBpVcDggFKEOTDjHFJ/hTWm20TerQOgh7bnd4NaL43GMXbQUK4Ce3DEcZ2oIzbilb4z1q/Ijfj\n",
    "IEvp2zhSTIkn0MGWkrZvh5YhlnK8NdB+SNl9SzaUlfwi/ZgSpxPBlHhMjmIonEp8caaMaglcN+MTEmpEUOOCbyXartOjG5C6kv3+\n",
    "qcrQzX1hVW5NCYBC3zOkxKOhJjKUbfRQTWzrEBAQCkE+20FNynjntmTzRnRN2RBmakofiQJBfuqsqVgQCfqIakEVTJ01KRMpd3rC\n",
    "sMu7oSznYIm6HBFy1FHDNor6c1eIQW5trZRs1Ucg3OuQwYRgL1yJbDNSil2lmDAigjLGel71Ds7pkBA3SQxhWfKjKwHI3y/CILeF\n",
    "qJRt+3J9RidWKga0DjthLDKmhKFBlaqlHJNftU1RY6SdsVBhwqxK3VAIOY8xyO3iKikz79/IzQAJ0cXmyskSGghkgyB6gJRpjzNJ\n",
    "TLjtr8yGckY+2T5gseUm3pcLKLGPFVPC59QqZ0uRzdtclW8TXU/TbO8NVb5NdMYw1Pr0E1VuBrR93qJyt6XmzbsFkBBdpa48bJNq\n",
    "2jUJEaJpS5IloBduqtw2OSMiyvaJqCpsKKmNp08bg6qvXCwoE5hYJdsWSosj1aoUW2pvu9A/SIlj6atUSxngZe0qyu7OXIy7pnRL\n",
    "KePpycAgJxVVGRY0hWc/f2ZlJDPb4X5RQJRYMquZDGV5L7zJQJD/8swGdL4LKnvQcMtWFlvdoU2GX44ooeuk5myb0vjpucQgv1/N\n",
    "xXZ7q2DTjUA+5Lvm6jqanhnNam62TXcajfdvVVZ53fr0O2olVfDc7YJA7aqkCj5zoXtHFgY5d2NVCsZKtCOoJGUvc0coTwcJBD2+\n",
    "TqkWM1kMeli3KhFivZzT4qDXqhSF9c5PifNJVaUnrFd0QMAkBj0+qLmvvl6SejUDRIkjDjAlxbV0S5mODn8IUlLDSnuDJmkI8quQ\n",
    "UiBmIEkqm1OdUiNmqbw7KUFCEImECcFD8VVpH9zuROBBk8QScnlmc8Sg4kHZtnWGAXiDRKDHAqB0kxl1MNAko3STmZ8njfgucFUa\n",
    "yh2g4Q0Fgh5f2l37tinvMSUMDK1Kj7nDYVrkTKxKneFhdIqXPlFKDV/vntb3q2pViTB3sINfVyDI2xQE+f2CknPkytBO0QcBQtwD\n",
    "2RJaj8X6qoSdGQKwv7sBKXFAaVVa0KSg5OJVaUETRCARcVVa0Eq8UzenFkgIbvxiQjTnKK1pEgit6gj0OBZfElQ90thQHPRSLznq\n",
    "IHSQvBaCHtuwS7M6QHdm8tcf9dKsDkoDCiYEeUGyXsJWVZETft9/iVkHqGyzE2FK7LG+NKx6vE5VgKcSgvy0emlYC0RHWLtdpC9J\n",
    "6ijpuhK9A/lLWPXSqo7qeDcwhjI2OR+u9h+BQI/feJABnZlRI3faYEM53SaucrHlZnRygiD5Rkn+zk4d2bapoy38KK6kEl88hoQo\n",
    "KXEd1TWDNvu20WwNhcDZcSgbyjqZ32upwxDW1sv1a0vJllr7c2sDQd7T2JKyoaK39i/tg4QoI0JLbAjM28kAUyimiG3WlVn5tVmA\n",
    "QCEhW0Iqu1AMTMk/p4RXB1sqhrI0No6+BBAiLaelahuFPFgYVLzhApAP32xJDax636umPehh3Qj0GCfdgM5p3oGGAd2R/1uQm7Ub\n",
    "JVtdqrF3FhJo/JAQzTmNyHZT453zEVI2o4HYUnKPTwmN1BC9IoM2zcqGsk0Q16jYOho4qTWqz4Y85mRSttbPtO0lqhoQop1Do24I\n",
    "KqPoe38gSo8pw1DuFJuvzUKE4LDUONlG0SYpa2MCBPdITGNlXucLFPElT0iJH/BpLIZy5nimH1JoQ8mWgqK0Gxfbeto83d74NuMZ\n",
    "Jzb4GZYGQQ9b52ZA54N5wU/ebamS0AchUPOgYUCnC8TagiQHAlcDm9xWNWOmjmRakcFASihoNLltbD1eRmcWzXeKGMoKJo16V7Kt\n",
    "o2/fL4SUzZQvt73NWJ6WDqHutVnVEYDbGIIeOwJYkl99pTkQbbY1kBDcpmnSHYGfh+omykDzt4L6MSX+MXIylPMmth0rCOSTkbVM\n",
    "tvKewUYKgoYvid1npN1DiC2LpVQ00eVsQfPIEbivISHy57SsTLucoQGBF7jlagjridvk291sqZI361xW9lXPa9aP3wuAvMez5WFA\n",
    "y1MSrQ0lWcI+FVUrZBvSZRdA2oqykJnwkp8uu1bEgJae6HfRCOQl61ayra7sNhqlWIJsc0u0oiyh33c+wl5ohrJuw4U/Trd1jBpf\n",
    "XoKElXThjaDsZRwxt14jazUZUGKJPSStkit1NwAq2xquKx+mGWJAMDFiq9mWdOXZeP9ZarHl1gHmOAh6dNRtE5zu9BLBdzdD+EZ+\n",
    "4la7oaTjEfB3m6jDNirBbkMg54trLVnQcdKxfdDu337GFCXapQppjQ1lXrnzqepakyfIh/W2lm3l+4yXmBI7XloxFEYPYUGQD+TE\n",
    "Jfkpr1X75WXEV7FaU1Y1c8IUMJpaN6D1/mOLuwpQdl01LKUCraYpGWZFgrXvgvyOAYEerk+lz8zMNAk81wdBj5OEEnHm1+VNirSm\n",
    "1JlV6n68K61mPVjG24OnkmFms1raLtBKYblyw4R7fKW2TIKM/ZcgSrzIKiWGs/HN/4hCG8owFBppM8AQIfzdlawzCbVuVlBIiHx/\n",
    "ShOaBEIbdQhy+RiakoomiPkp8GCQ34wqFWhGkc2cJv0boMfZSklFfB0X/DyplJ/1q4B3h5tSe1bgH2+8XErtmTFkx4srdkqBID/v\n",
    "KEloxiOVsh0yShTiazsfqsSQEm/ouxKM5vtgiXcRXF2pQZPC9bkadyXnzLgfznG2LUxIEUEsoZ8/TtByZUpv+ey7Ukpm8EzbhF5D\n",
    "QhRp0pUgMrOesMR5EyAhkg9wDRI1qdlvOB6VCmroroYeb0UhIYVNUsY8zgyh/T3BW1cSyAphkqemg0HyDZB/R7krNWS2jzfaMSZQ\n",
    "RGBLyOlwe75bOKLE/k1MCffNXYk0PO7Hul3/ZPe5vMu4CSkUaqddaTmTQgSGNAQN3957zKwH3kBm537pN22lPGHZ3VKGlNhD1i8F\n",
    "56DUTQBBv+SYRVgZAd0uul8STFMPKYWxZP0SYZqK5fAdB0FOqemX7LJAaYx47YWEcFa9JJe2giimFuSN8hJZDtBgMEdcIku7M3Rs\n",
    "RsSloByUCjRuCPLbnc7KXE63pL8p2Lkb0FLzXb6zzsOC+m6+kWQJR848+0MikPcWdFEm8dmItPXirSuJLUh6rIV2EUuguhM2MUV+\n",
    "TKF4mEi2lE5HDvqAUgzlVNNcN1Zb7nQ8bT64uXLBUblLt+VyeT7h12UY0LpFFB6mela2caUhCcN+eiZDSUW2P1BWdtMPL9Na417s\n",
    "JoshnHmgbF/nbEBzI/zYGORiqz6CPZoFVQvKLY4ygoRwa5TvH/l+EigaOYgQqV6YMKI+7pZwP9P4RhiWMKOCwiTWmBK63Xu5LZKu\n",
    "9Tr6cESgwBnSCxnC2tXFZy5Iifd15bb5mYxi9wZLL2II9wu/QR3Z1kG8OWWUYgnXcSqoA1HiX71U+yVp2yxlu3JmsnORa710C0Kv\n",
    "n/QyDIjuN2reW4sooWu1V2VyZ7joY++AQI8tbCUL2sdQ9sqWUvP2E6syrXKYL+XQfGs2lLWpcS4jCHpM0xDEvh+KBSXw4hQGPXq0\n",
    "2m9ttNmFIkIUGNVrM4TztbHwB1C2ez1NGE0GdVhC689QCgiaDzdqUEsGxAlcwsIgd7u7N2Wr7fSaeJNobEDM/XmTozdxJdWnLo9B\n",
    "7EHZgNYiFm/9WnHl0nbL0+qzKQ8Tbsos+q11v9sRJPD7k5G9KSu68soGbmZICF16bRhCOnKARRNFT7aOmaPZPcGAQcWDbvta8uoA\n",
    "vdzZgFY4g7eKLgaUjlsP746ofpvRkkE3STIhIXTX9dvq+Hz6LgoPhoQoOgYTov1cv636o/AkdBu092ZBaZOECxI48gr3bgjrgoZE\n",
    "tyV7v8103WUH7yn2kQwocQUuQQhyjxr1Qba6yptxN5SRljM0MHYgIcpmpzzEUNaqHk04QxltvSND3nfKoxjC7TkIGlUtZesyHcpe\n",
    "2vnGULTfH90ROvpdhwHNO+DRiW6kZAjrFE4/JER5h3ANI6qB7Dd0OWMz3gjKhvr90pgx5pHEgJb45RyPGOSyXIykzGlm5QRJQEa6\n",
    "TWil5gbRDSPdRjOvf1aJz0ojNUO4HaOmVAQiDzqtqR9+WI6PzeMSOfrh6W3ogwDIP8E3Ls2ir4uLheOpdVxyRD8uMdIp+78Nx3FJ\n",
    "BZrinEvjEgcWaN2JeLQ2W5C02MONCYHLY1w+/6M/Ej1jcMbl8+/LdT36U+AdlwO/H37z7cPOmFJiSndNAVkJBw0DuvNI7UAPc2Zl\n",
    "LkpV5z3ISduDlR0VHXf/9ssgQrTbwTWUiMCW0GosTg5WNlv13QbzodmAUgUP7GBQd7MmFwdaHoqgfYgQTd5cDWFlyxz+g5ottchm\n",
    "EHK3paY1XILHOQcPSzlSZL6PW1E21++dqu1lCPKTsSizGeel6zCF0xA2FDpeISs/pVBMEUNZ2c9y1Ce3Ua2rN+BFxiHFgJZaTNaj\n",
    "NaTaku4IkbeqESFaTKUZwvK+hf4MTKGY0i2l5ed1dQzyo1puA50OXwGi18jJgO5Ei+8tzGQoy3MU/ciQUH5ICJfEzJZw5+R+/wqx\n",
    "lELx/c2Rlanyi9Q+cjGgdLysJ1vQY63L1VbX0TKWlX3lO7jlGyC/amZlUeVMSegnpzwM6BSJ+h7kFNJRlNnNyVPAxg+BHnNhIQNa\n",
    "kXbRigMJkXEVNoT0DeOClDD2bBRxlK1+gCmhSjFKtl0qJb5VNoqy1XaHOluDLtWAvvEO8yjNUPhQKcZ7Q7ptyEAHJAiq3vaGbe2d\n",
    "Aual6posoZUjXkuXikA+SBuX1NxKVpWBjjMTkPM3jcoWJNuY61Fvc5nePgYXEiDI68CjZgNaifyqbyEC+QN0LQ4EbmiMepvXSuia\n",
    "d8FTozZDuc+MplwE8lNs7Qa03gVi30IAekyx9ba7Oz+rL6klA1rp/fzKjkCPXoMgv7VqZEHC4CwJQX6VaWxAyyvtf+4mrk38zEE0\n",
    "mjKc6RkdYNpvxYFqHFQ7WjUEuvwRPyJEfqimzOlMLxolbYGEcCJq3RDW4SBUcUZTRlfO+fvRmQDkdeXRkwXxfoHqynDqiwsSgnyK\n",
    "xNHZgo4rgO9d1cUQzvdo7GQHQX5EItCzfdmCBoMNOQT5HWRXhn0pdaHMjinxBqVXQ1lifuRH6M0SjkuA0WGwd0uhvBMQMSU+YnVl\n",
    "u1dezDCJwRjJUNZb1k5EH0OZ5Xnl0scuQNDDyzbYgOhOmvXewtvmrocaU3kX5jAhv2ukY2RL2GUtwYQaEYohrNmtB54dSOCohuo+\n",
    "ehN5iwnhRzdLSOBC0Bi30QodqeTjwJgxhqUc2x5nXAhkj4mUUrKg+lwsX0DWp0ApkQPRmY0Vds4Lob7aHKXEhjCTlAWZfl8I7xZB\n",
    "KYkh8PWErvlQAHKhF5Qu0WYcysTuJiCmhGGllC7V56BQHB9J6VKAFoGP/LcS1QEooduK0qUIHc1Kuxw/mBJmc6J0SUrj0HXosRl8\n",
    "AQ3/Yw0D4hSngMOEYKtG6dKgFuHOVqubcelOByg/b9O/gGyAO6VLjhpLZblePXxtHyKUiCCGkI4oiPYzAgXTAyJQ2MfZEOZF/I3V\n",
    "IUraUIpt1pE93P1ICFT8z10tqJeNFPZCicciqYE1b7eWzf4GU8IFiRJ1S+m02UK+UEpMGYay0nOy71YEeowiBCoOxGrQzgsd7bHP\n",
    "ewH5BZTJgnJ7HLgxyMX9vZTkl2JmCwJOGEosFpTWCadaUHYl7SY6RFh3T98Iaqi0Q/IhP54g6PFLVANac1iO2tosgerjlS5K3C0o\n",
    "DTDeeRjQmYvwvWpRVjNfcJWH3wSDHrsRIQNiKcC0hA1oFy1ESZRxjNONR8FWS7IhrLvBOVhCpViC8HaDJPcPTGeMS3A7GBPC/Z80\n",
    "Q1ge8RF9BSAE901eaoiMVLr9hlwfXj8MmuqsAd1Guh4fTceAf60aECiympwMYUX5DTc5QZCfMPNt1VO9Gh3M9BDkh2ZmA/oM1dj0\n",
    "s1hC62D9gKDu23ePiXmTApz+XkCPjygGdL9c8D5YIEXCnT6kxBuSXA3lfqzUtF7ZfD5VfY6bAijhJZ0XSrzRyd1QKPF2+5eHrQUE\n",
    "41EqyYLGOB+cfbE5SIiMtJBteZHN5rioYVDOIMcc+N8oFWXj9VzKq/9UBGrGT0qpZAfix7UHDCIrDlMqxYJSAoMTgdgf0Es1IHRf\n",
    "4/OC+w1q96Vo3oIee8nSDSgdz7cEJgEIFC0UZdhmSD/zQr7+wjVZSkvbE1Ml26zRgPMDgvg7IN+3lcFHfQfkBM/Ps34WxHFsHSaE\n",
    "e7qqDLifAR412PrW4ghoJazVgJiej859HnYxoDQKcPVBkN9z1u5A8KdTpjbOgBxfUksWVNaaZWeGRgaUKoWq2guhBD9ju41jKsRT\n",
    "/PUTNgLN2dGAxIBSjyNzXwgt+rhsCOdL7PIjQng8a8U2KT+z+2PQ49zbbruconoeYdrOT9Y7S+B6ZOJ4JXRDWGH2fl5tw4Bm6Eh4\n",
    "S/OTP8c2ZHNHHBNS1HJIkKBv+m3V94Ou0cmmK4OV88mlGs7cXSylLHkk2gF0ZV5ZJ8l8bRYghJNsL4aw3L5+dPZqSx3nhcjXUpsl\n",
    "HJ/a3wndEvozDu5z2cW2VTo4jg/105f7xdZoFYaU2BE2yFCWvUTnVEgogfd1sCUM2igLQ5lXPa4eBmmpMSFJ1KRsCOsb/NIEQfQd\n",
    "UPW/ZPlOdQjkp8pR7ZdSC9NyfAIqbsIpyJP8kBB6FSAh8vePbghrqxg2adgarkeZXq2akhoI/bzJ8h6K+ULoEYEMYell7zfzXgiB\n",
    "B4kSG8ISc39KCLJZvxByVINYwnimf/ocbG1XUg4fKPycN27CuMN06zuhWgLT3iCaoayAF/Et77bcLrv+QASKCMM2g+J3hz8MSzjO\n",
    "VcGMDimxh4eUEries+zxAYWUyCfXvs5t20nJbnLtQaPuhISoO5XoJurOBv+MEI0pJb/Nnulg6sag4Xujuj7meJ4kpZ1NAnjL9QVU\n",
    "PKjbXwu3D4GyL+mwX0rr7uX1sM8O5A9+dIpdJ6htts50ylUnQfIm1SHRKV59KFci6uS++xSvDlA6khOXPcjGXROdgtUBWm6naOI7\n",
    "taiTQBtJjE5d6kOQW0aMhjakjJ9Twl0knfrXh/LZ0NLzGSIMcokgiE6R7AAtF1g8t0NK/TElvGtExMNQmNH4gKDuPlGU6Zf7ybtg\n",
    "Yw8pFH8irKWGv6KQoawIXfk5pcTfwpbSx9OFSyIWJCvX9PueABHWk2NvhGwIK2w78BeRFEtgoKGSqLFTj4BOLy9DUMq+B5oBUeuP\n",
    "e0lE0i1IOtjWQJCfj0VZbjtD0R6/CgB5/zTlZEDnhWYHIgNaFx6c6oVB7Tsgv0xmtg1POT43QULkcKMsjtCfzlXK2ba11TNO8XW0\n",
    "ZGV1/X60x/4uEFR95dWAlsNOfEkA9JjIIejRpmZBt+721oPKTMftJ3ClAhD5/U4eBrQC28IIQCrJUXY7eUSIPEm4hmjrWch+atu4\n",
    "RancNkvXrSpvgkUMiAoIfMIgP85LtqBDUJEt6GGZpdiGV6CTY1D3oNsy6XoaPvCvUmmGcKb9CDq5GwJ/7pKRn7jKsM1oW8UcU+JN\n",
    "V0227bR7BfmFEh8n622GdG5SHyeMqkxPztjY5EFiQDNKe7OJgZR4w1uzpZSx3UAiShwiQLW4Dx7ADGq1oNo3Q74qa8z38zV2SEGQ\n",
    "czlS7RZECRzQ6zCgz/IUH58RIQouoJYM4Z6SXglka5AClnwIclIuNWWS5QyvIF8SAvlJpYkBrb1A5Mdoyp7q/VJlNPxbsRQiMMoQ\n",
    "6LGCt2pA65P8vrE1W9JUV+JTYVMWdSbx8kottWFBrQOzQ6DHzrYnA1r3WONhDCnxfNiVJXUdrG6awgaU4G8DQX7XC0HVuU+6WJBU\n",
    "0Ic9O1D8pBJRV7Yzzl/m0b5qQOtS+HC+kN4MaMW6DhtdQ73b6q54qB2I/OagDwvKsj3HjtsQlupcwLcOsqBBu2h2TBk/p2xaz4Zy\n",
    "z+8/pMTL5BBDOcOffkrZOE1GtpQGLqtgkJ/OR7EdSWNj8KNawvFUXdiNzXYjp40INLpteeZnzCIG+YVo3Fa+EgsMZLII9J2SvNOT\n",
    "kxoh8x69xK5KTuQIPUyGiglRFDcnNoSU6y7okpMySNGXrcynApC/FcApWxA9HybAIG/MnIoBJU67oYgpNaZUQ1mnBfYfhUDk26ts\n",
    "/rrIv6m8G8qZTduVi0AugJvTeIL8yGdKBqRub722kJTxlTM+3TkoMKhZnz4TG9B5eZ/2oOw/A4H8T0Zi29TS87jMlG1JCXiimYoB\n",
    "3bkcd6DHXAFLelRXbZt6/F4IMSm7m7kMyvPOEAT5UxaTsrSZdDU9RWkIeowFJVzNyITjd7Rmo4SrFb4wdoosK+lqUo43xootV9mH\n",
    "ehLvfcKEhBYRxBKoxQ4lVoLWUqvja/DEStCaenh9JoPDIL/rY6VBTdAAwfEQ5M9DrFSnW3LvHnSbh6gNVN6D/FygFKAl825/RkCI\n",
    "IvxZCTsr9e5y+9h5C4H8aQ2XJG4MKoVngo4HPYPDLKbEQ0SJQvMGPz1z9hArjWeCJG02IUrjkeuOupOrMchvEC7xh9Rt93iZhJRN\n",
    "L1RDSTk9d4F8CUMLtGK9N01BlBE3BVF6/LP3m8JnYha/mkOQ30Qh0IwIMqBhQOmIj7Dj4BKjDlBuYEa6JCRS2ZSDGEi+1CG67+BH\n",
    "wj0kBDmriS85iZYGX9uxFL13P6SEF6cxZcS1ZEs5UrcFfVUs4Zg1a1RHNZQ1LwUBYZybJVw+6qAOZWL5TBQY23cehrJedA/yDnBJ\n",
    "hnDeCu5BHUWZ4tyUFmCvhS3oCh9+bQgiRBNnEUNIDC4lcskOBGJfIcg9HENclI1Mn2WO70NwqZbAslNLMCV0eHBphkIFnNwxyK+0\n",
    "RVlbO2PyokW+DEs43m10pQLQY2dekwUVINNB0GMNrGRA6xJvPMoQZeZLDihsm5IJHFyqsk/1bsK7wdRsCOsRGn9IqMWAPhf7dtNh\n",
    "rbbcPLaTLqTEHoKqbGycPowUNwxQdrV0Q1khNP5AXIctV7ZuNW7JlVsfaXiJ220pdL4S9JhxEMjfOeLGBrTevwmvInITW67k00xf\n",
    "zAkS3rOivRBqsHa0bAh8JBx43zG023jXFXs+F4X3z0aUeEBDSuyla9VQ0pHo105ACORjRnFJj5+/2Rb2OKcucbvNfl3rl50WBimb\n",
    "rX27x83UglsGMywE5e+A/NLQkwFN/Te6ZMGdbKlDjhXKlMoGRMeLItF0Dimf02pAEdv21MF80bMDAV84d2Wtl8A9vgPK3wF5I+7V\n",
    "gJg6mJ4Q6LFr6s1WN0uKz3iIshmZvRvKCvne1DIsJZXNTnIk26zrqvArgSyh8e7yOQ9lY+V897xHdYgliAC/88gOxLHiBAkUEool\n",
    "0Oa+Aya0YFSPagh8vYJrPrQZ0Dpt92BtgoQg1pZHt4RWwZQFQV4xGMP18fMdV5KkbK6evotg7cSE4EAliSyh0fm4zJuRSmJD+cYt\n",
    "NkliKSz7WhCFf06JnpolSdlQdg+jkaRi69jdMsWE8BdRlv7ZVdf6nOolNQM6Pbi8B2VfUjegpeY8qhu2TfJ8//gF5JKVCCkDHWew\n",
    "ultZIMjHXAiRAdEAcSoY9KiODeh8f4v3IPEliW24UBxWJpRt+1qcAZiEivugEt+lFLrNacUk8PP8LdQM6NRHHOi2FFaOVNqDyndA\n",
    "LkRfaBgQH4c42yZOBrTyw4YLP6TEZ2NhcpT2nNAxyI82ZvvlReKbrcK3Rc3oA5Jz+/ve2mwopzm/LxpcDGE5VvxvBkHD/xwA9Bi4\n",
    "rCxSzmdKXBIUDGoe1Bxok91QuFsCxa8DvBCi6Z6HIaS5njq5TyQZ0NpKRqUKWUIFQWgibNsqaXduFlHWdUW1urOSSDYgan0XtiBK\n",
    "MbrDITZNQZR4i6BEptl62kanihKD+MpxGsgXogSc9ULDJqevKDFnxg8wyP4mSsyZpV6p5bcgJ7aLUnxmHoXedoclURLOjEVIdXc5\n",
    "DlI206xSZGbDrgcUTOuzLTej0YJAj4lViTP3C7exxUHKiD+qGsoKVt30g7K4ft+/eh/riBDdx8E1RFsCpRjNBA4dJDyCoKcdDwOi\n",
    "Kzf8e38oKYn77XCsEYXsB1LeCR6QEj4SSKKkpfnB9x2Hd4pYSgYx/hDkAxhEiUsrogVoG6LEpVkSil8SpQ6t/BAShyuKkn1WloW+\n",
    "WRMhITqUKs1oPdZcwOqlJKB5a/+4yPw+0SLCyt30QlDS0CSk8bzaA0GPs4ASg2aky0C/OwI91lalEc3qCj29xRDkHW2ihKT1lkje\n",
    "nsoRJVZAcC1hKie5BCteoSACgo0wqHpQMaDPJYTniQCBfLimXMrVUR2XOBmZXCLUUeqVrTP4bkTpce92S7nsKqCMm8JnKsQS14Io\n",
    "8XR6CV+LslJzR+faS986CFLDl2MxIYU1sCU0BqMZgvxovhStBVoR4bHbCFE2W6iWLaW2neYsrdjWlx5rw9KqJaS6r0NZ6edkkdHZ\n",
    "r3UD4n0SZEyRuFOHoSSuYHhDkF8De3KgDHweEETfKcnFX0tXtptf4oqlswEtieLRJjGgddkoOtD2bEsl3mWrlV6edYT7y15tHbXG\n",
    "N4shIXRq9WY7MHXgg+zdlsoCDhQQ5G2oD9sDLe16ABHqTwnRyB3Kzsp5ZySe+SGFwhE2yFLamQT6Z5T4arYMNpSVgMNpUjLEgJal\n",
    "9++A/Fo+1BCo92MU7z8OJNAPCVFicRnFENLujpGMamv4xjkKUTYjfzT3Hdu4SkwpsZF1Q1nvLriroTKGA23TFuWUbKfWfmREUOXm\n",
    "RK7n03NXkJMy0HZ6asWXBEDPksSA5uoZO61yypYytrsvSImtI6diKRW8wINB7EHVfWJ6LpMQ5G/S5tRsj86DcDDuMKFEhG4JA8z+\n",
    "OQ1XKvphKdkPOtJuBsEemchRxu56QSZrY3ysVu8fCAn1Xa/OpAz0epYzSB+aKRvCnTnL9E6xpQo/T2sY5Dz4maqt7kps99o+ROAf\n",
    "EiJvRaZmCGvLN/zHdftxnJ7+mkzK0saZUHZEk2fmZCjEm9zvmBCkR89MtlGUdolIMrOhrOhvdz8BgtxL6JRZbOV9XTd4//U4W0Iq\n",
    "8UYqc7GEsQnrwYQcEW6DmlGZGexDMcgdCTI3C6rg2bzM3YBOASL4gQEhCpjLPGwzjsybQRL0LMnWQT2+GwYJ4e8oZAjnkSBa8xAl\n",
    "Tn6Thd3PRGBxgyB3OS+L2MqJ42ujWbJrbXpKHxjkfMdZiu3b4/pvNM2IMk/6hqoFCfHnNUNY7xsFXuUs3RJQavEswzbjCNsMPxVR\n",
    "WqQ35JxsU6SDzRMCeR9DzmQrJwhS9sXHnZHYhZezGMp508+1MNty29isJYgQLsa5GIKKUH5vebV19PMFa9NyBPJmn5sFjQGmWAh6\n",
    "/ADdgo64saCjlE3JeYCIpr+SDGE+7PeYZwrZUvNWj8yFbbm0W7yKWEJLsS8nl+xa3o5gjVdCeRLCzkSE6EJBLtX2k/RnJBYGNauw\n",
    "59Js1XUTYZ+LspR8xu37AwYCPfaGZRgQHcEaRtzPNVlQkzhOJFeyVZd2LgVvBDYETiCPEwa5wK9cBYDGN0A+6BWX5KLDshKMZtfU\n",
    "FsvqWelCK/eZoKqrA4GkoRjkJx6l/szw3rJ5JCEr7ecKGo5iriEhelU2K91nEnLedRkiROanZKIZ60pAqMxK6ZmgVp7vSmGQH75K\n",
    "3Vmv46HjvpJqVptAHAoE+UQwWSk4K5J3I1Flpd9Qu2+uvf9CSr+Z+eRSAXtCpdisN+I2rwlDQrj3VtrNemaPNzOyEmWuN/N8rEhW\n",
    "Ugqd7xU/jAOC/C5Q6S3rFTqgA2PQd0ryGn1Wys1sk5TtiVnpOPOX5wp8TEq7WeHZAtYxBHrsHJSks0DgzhkE8aNN1YDODWv4rc1S\n",
    "Ej2TIGaly0wQ8+ZQqhSUGed8HBjdLwNAPtEyLsllRMlKTJmB2x0tcUoLoSuvs7c7pX6s6mjr2VYyx3oTb6OuZyVIrJjutJmJECGK\n",
    "Gs9KweB0v3P6/nspOYLTfTE/OsQgyubco+SImdeO8u66YlZyxKQIeOAlKzniStz3OKdBkBtARakU87es9ZmwqyiVYoKOGLjXnwMT\n",
    "KCKw+/3qM3a0KPViRqzLJu8SJpSIkA3hfo7HNAOAnv2KSqq+X4ttX0/PwyUGuZiQogSNWV0d6GdstqQMXqPBoOy/rhvQ2heHdg0p\n",
    "sShUlPIxm5I6ai8CuT1IUfLIrJx4l9O2KHlk3kqQ/HRlFaVvrFyLtJtFMCXHFDGUVIDfFYL8wluUWjIr7/W5p8EgPwiUWjKrayAX\n",
    "Fga5XXFRgsesLhHqaADy4kxRSsisroKVsyglZIFanIATEsK5TMkofEY1+RvLEOR3IUWJJeuFUInjODAhaqtSV1YGTIlDXzAhOGMV\n",
    "pbPwGVgVB20Wpbqs9Jk1dgRCQpTJrSiZZn5FrbGQCQlxxxZLSOW4fvQTQqR8FiXZTEIHGwUI8huFonSd6zeNDlyQEMVsF6XxzB9I\n",
    "+i6QpyiVZ9lN20WwQkp866MonYevu/thQASmcFyLGjTz2nbZJU2ClPhggymbWthQzidErXkoqWhlGG27aMqidKD7qVp3wMMgd0W+\n",
    "KLGILzdYZGqIEG4SlVQ0r+tc0njwec1Sjth/123dgOgIZQi7DVA22xQlF3E9ZWy/8VPazwRxfqZ8xSC/BVcC0WxhBVePMcjPTEpF\n",
    "Wt/aY/9hURrS9RitdxpAkPcPF6UbrWyzjEpCIPIfAUCPDbxSnbi/ZEIsSkKaoLSNvi9KK5qUlmL5CxLCnYwSmviKGolNWElN82JQ\n",
    "6qDblLx0XwyKFhxIiGYBpU2th2rRr4dA3qFVlGQ1bw8xkACKkqlWPlze3fiClPjNjKK0rXkv6ZAE3n8/JVVNAoGon6KUpwUaOwdQ\n",
    "UToUnz6jxwkBgvxZQ2lPkvQT5VuQ3ysrgWpdOhq7tJJFyVWS7qcOc0QhW0uXZ6a2ovSnCWore+H7dhQRwg2YkpxWQuQG+lZJTuup\n",
    "4LRzPhUlOq07YPUZSVCUniTXYw0j7jZAIY5/HFRLGGFelIY1P1gKMEsE8vetcEmPflBmSS/BOBjk3RNKuJqJkBMQhooSq4TMvvPF\n",
    "SJQmtdIr01PlKEqTkuuOWjSzKulpEkaNUz5hQuBKL0q3ErpP6LbLlFY1QaXtLsxiShivUJTANdtewk3K/weV3t2F\n",
    "\"\"\",\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",
    "    \"customers.csv\": \"\"\"\n",
    "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\n",
    "MbA4youwiqExZ9I32UIn978sij3j\n",
    "\"\"\",\n",
    "    \"supplier_prices.csv\": \"\"\"\n",
    "eNp9lF1O20AUhd9ZxSzggmbGP0l2wQ4qlEQVqtogCA99swlVKpEKov6AKFUpVKISlRp+XEISmy2cuwVWUl3baew05cEPY8397pl7\n",
    "zszGZquxXW8/W2/Qq7WXTaqvtZvPW5uvaWNzvd6kemurTVvtVv0FbW801trNxtKqNsTvOMQDB4g45BDJMoeYpIueMgoj3CjCMRLe\n",
    "RURGeb5Wjzsx1awmI5+3os2K1dZfWtU23YkxdzDBFYa8r6ynFW5mhEpe72pNvkvGKZQ7ImYXQyR4KBQX+ledvNrXRMYWal1CH6c4\n",
    "wCGO8BlH+CXqj/G+KN6pZOVVL2vuFwDe340KV4hwJ2dP1xgixuC/J0GMiHcUbjFAjJj3+A15tfLJfFIKF9znQDbgXnEXA9wrozPm\n",
    "haxoKs/4mox2ybgFRIVwiAhj7j+FcG2OqArClBFVwnlqTjxX5dWyKlvRVJ3rWyOccYABrnkfMRLpHOIKCQfKK0JsDjFGk3EqpQEY\n",
    "LQP4gURmijEGuFW4EQb3MMIQ94Rv2X8kHPIOhmSmPC1eF6wyhvAVEXcRIeaeGJbgmsNcX7QAlVslQ3GqZWVWlJ2kHouDY+4pXCt+\n",
    "W9S6iOl4s0lXbVmgk3olUebg30prnw5OIdbGJYw44A5+Y4SBkqaYYKxkAsLrSDX5duafTL9I8Ih35XYj5u7y7KLPEazS/nTcrqZy\n",
    "fI0vGehwkGqQ1wG3ciV4fx4yDZJxrZhWZFTSKyBJQYwhRgp3EioMOEDC3SzUZZ5Rrsl4j8EH8ku5NFXh5XcRk9QpUSfDElqm7gAj\n",
    "DrMDc0da8R45ys2ts9pokjwUuTXC9/QJ+YkznOJSoY9znOAAn/AFl/i4mGr9WVo9XTq61YQzRBykMgt2LiYZZfNXztfiZSmvfwDH\n",
    "5s7A\n",
    "\"\"\",\n",
    "    \"returns_raw.csv\": \"\"\"\n",
    "eNqtml+SHDUSxt85RR9A3a1P/3WLjr0A4cB+4MXeHcYP+2bwARgIvEHAeg3hA+ysgwEHXuMrqG+08aW6q1RD9yDt8GJ3uLJSysyf\n",
    "MlNZvnh0+fTi8cefPlRPLh4+uuCPv148efj0k0v+vKhPHz64fKT+9vTB48tPL/+uLh49+OzJ44/+ojUUNP/cISnEjcbGaOOVUeU/\n",
    "+2fluvxKIUMhaLVDUHy81lgjK6jyofxW3uyflZvyc/mp3JT35Xq1f14+8E0+Kr+Wa2qwosGonYYyaJf5UG7k/Z/2z8t/y3V5R3En\n",
    "4knttJ0X9Mqr8r7crGTV97K9N+Vtebf/fP/limvtn+8/5+uerxundjop46bV0Pl64Os2qB1/+fb1D/tn5W15U27KL+VdebsSffsv\n",
    "ym/7L+Sv8qZc759RR6QOp9VOZwXT6nh1yuBE8VDFJ4PDiIczNcSlBoMBDdDUkGz12lFDpIYZBQgvmYE0sxDt4kPhJKdGg1lrt4QJ\n",
    "RAHIaqfjLISqwTJyzpkm8O5A2im3Be4mJN+IhzVwa8tuWtBLKMwczhM6wS3A6IUR0DTiVXlRfigvyjfldflnuSrfU5ywwKNxiGHk\n",
    "ljaTBgS71Jl6gaSkRgiyBvRkgel8nWgY7dWOZhxWN7L6V9MWDaNvhECvdNhoe+cJJVnaBDrV3dLZsyVDO0xiRsHhdbvWsca5i1Zj\n",
    "RUNueLdrPZKTjDtqaPYArewZi8mF1U7t4GZxP7IgUbHGSl45aqAd/RqiaMjCO/xW2+2dQSI31tmadY8LunMoG3Jio5eYWj0hUE+m\n",
    "lodx4W/+YwuRJRU2BSFNp6122+HMaYmGAytSnFKATmM6CIcTT0el00a75sh3edoSDmd5ZoyUENfk73+Vr8V5/yjflBflZblaldfl\n",
    "Vbkq38mj78rVeCqzhMP50Ij7mhYHzI6iIy6WNKni8cdmr6iCyLhQ7cbsOTOyDXLk4tJyk5UlR44cuRQrInaj/ezY3gUcREf6/QID\n",
    "OgQzqVV58jdwrio4O4mbZXh6a4ATonIWrrVvDT+5IPHx0pj52UZ3VjyIuKnJLLcnrycfZ3o0O9TkljY6jbc8jvR51MiaxaHr2YIj\n",
    "ed7Uc6/N0j9dZ9aRO2/pgyynftYwh8GTP+/S4pz197I8I57O8r7yjbzVfju4U29EQ5pakrtZ8ITPS2IOcih9k5g9ufLZHdze7qaz\n",
    "lHp/1NAchZraX3YEzhO9IIGbj8ZY1vFRVNilCncuV/ok4m3zEabaf0KcXAQJOZTFIuWcKIOBhIRgxKFWtw7t4birJw0EIOrDfchv\n",
    "dJjSbGd5CWQimigIHZe5fXELToTSQogu7jSFYERbL4fTGmM1KZCOKIclKo2Njk1f2YVniKLBNV1MOODZZUSS18PidTPSKAbyEwNE\n",
    "A/NKbI5fJC0xuZr74xTJMyxGiHhu6IhrfStHRaEj60XYqsUndRKFJNfzmo/ittmfk4e2OSuROHYiEIlAMrWu8C4ch64ekdFPVi9X\n",
    "TwNtVIyiwVaHLfnpZTASgST1OU+7MDjXNkfGO4XUEBMPJaLH5kQiUqzpFLnB5WS6SRDx2ojw4pVG614iLomXKeFzq9P293UvCSTs\n",
    "u4xWJkyQLPJFEliyXlhu9cBhTcJLjs3tPq3hRqwhMlm3jVliM2X7NUTRkKWZwaKZ6ayIicBktjITtmmNeDaEWcSzHEAyOhzCTGay\n",
    "HDPXajhF6OpP6tcyscneN6HKTEUjJyuTqhxcvdK6dt9dbaeT12PTOKe1xUCkMnnLMrQyCm6j853pNwtc2TcL5kP67Y1UVOBkT9DQ\n",
    "ywW7vZao4zifPOwC+lw+ypniLMMTjfn8VQVai7hfiHPgckYcFHfN5QV6rU1/igaF2fnP86VJwyl4wa6ZjfoCPJP7sww4HwY7rXo7\n",
    "3+q8vSPqYIChZfyW5bTkwfMJ9t3QkmKt0nEDXTXYLs6hBZrs5skPPZTO9LfgEwC1pwC20CMdKFh2AFOnjQjTZn23uRwFA8fm0rUb\n",
    "eFlerASMl+WHclW+Lt+W78uP5d8rweF1+ZGvEynYwx3LThs4zTc4aQCEQKswi7N/AVsagP0jf8V2K73GONHQtFzQ65EOFJzyAcEt\n",
    "AD+644/jwfEAEA6DRtPY170B8oPjnQKtQ3uAgPAkRTnd2sCJcsZROcVvOSws+gkOyAHDMo1G6NwdDLw1wNgaY203mD8A9biQ6QvG\n",
    "xnoDnl/vdyHnrDDMAcdpL8BOdKDagTdSGLZqyJMOOoKgcjcwsRlI4/BdoytG9C9Mam45OH6/6XJQlNd9szpqx8KHSR7Gud5Qdxrx\n",
    "HpEwOSzUG63cgPd4gYHVLVbydajTQWwLYNFMFXH8PHVy9n3fYTs4iIZF05xwQTeQNziGhkX9HqbTFmYoj3MGDRuaOjk5rHcDXjTU\n",
    "wMO1G+htd+7bMN03eXIuDifNvJZaYpriwIE3nIwc5zN36NO7seThcCY0h8Pckcg43Ibj6G7KI2asmnACDic9c1xadLpd4rQbLjbt\n",
    "H8zQpQycdcNF2+Tq4+fHUyZy1g0nN9Igk2pJtqG32eGgGzKdb1YbOjecfcNLbTGzhjDSY4MDcXj56puUyZOXz/QgHF7D+0OBiZPN\n",
    "/UNMcIANH2rG0W6p4sSKWcSnlBr4JX3s+gUOsyGD4QpjOPw/iSEduOfllc3ZCF4yzf6/56uQefd9RqeQeXf/ZRsy4L7HhP1/mlrO\n",
    "AQ==\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",
    "orders = pd.read_csv(\"data/orders.csv\")\n",
    "products = pd.read_csv(\"data/products.csv\")\n",
    "customers = pd.read_csv(\"data/customers.csv\")\n",
    "print(list(products.columns))\n",
    "print(list(customers.columns))"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "load"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ключ называется по-разному\n",
    "\n",
    "Если ключевой столбец в таблицах назван по-разному, вместо `on` указывают два параметра: `left_on` — название\n",
    "в левой таблице, `right_on` — в правой."
   ],
   "id": "text-16"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# product_id слева, id справа\n",
    "catalog = products.rename(columns={\"product_id\": \"id\"})\n",
    "joined = orders.merge(catalog, left_on=\"product_id\", right_on=\"id\")\n",
    "list(joined.columns)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "left-right"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В результате остались оба ключевых столбца — `product_id` и `id` — с одинаковым содержимым. Лишний обычно\n",
    "удаляют: `.drop(columns=[\"id\"])`.\n",
    "\n",
    "## Одноимённые столбцы\n",
    "\n",
    "В справочниках товаров и клиентов есть столбец `name`: название товара и имя покупателя. Если соединить всё\n",
    "подряд, pandas различит их окончаниями `_x` и `_y`:"
   ],
   "id": "text-23"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# name_x и name_y\n",
    "both = orders.merge(products, on=\"product_id\").merge(customers, on=\"customer_id\")\n",
    "list(both.columns)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "same-names"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`name_x` пришёл из левой таблицы, `name_y` — из правой. Понять это через месяц будет невозможно. Свои\n",
    "окончания задаёт параметр `suffixes` — пара строк для левой и правой таблицы:"
   ],
   "id": "text-33"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Понятные окончания\n",
    "both = orders.merge(products, on=\"product_id\").merge(customers, on=\"customer_id\", suffixes=(\"_product\", \"_customer\"))\n",
    "both[[\"order_id\", \"name_product\", \"name_customer\", \"city\"]].head(3)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "suffixes"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Окончания добавляются только к столбцам, названия которых **совпали**. Второй способ избежать путаницы —\n",
    "заранее взять из правой таблицы только нужные столбцы или переименовать их через `rename`."
   ],
   "id": "text-38"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Новая себестоимость\n",
    "\n",
    "Поставщик прислал прайс `supplier` с новой себестоимостью (`cost`) и остатками (`stock`). Соедините `products`\n",
    "с тремя столбцами прайса — `product_id`, `cost`, `stock` — и сохраните в `compare`; одноимённые столбцы\n",
    "должны называться `cost_now` и `cost_new`. Переведите `cost_new` в числа (в прайсе встречается «нет данных»)\n",
    "и добавьте столбец `cost_change` — на сколько выросла себестоимость.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`products.merge(supplier[[\"product_id\", \"cost\", \"stock\"]], on=\"product_id\", suffixes=(\"_now\", \"_new\"))`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`pd.to_numeric(compare[\"cost_new\"], errors=\"coerce\")`; затем разность `cost_new` и `cost_now`.\n",
    "\n",
    "</details>"
   ],
   "id": "full-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "supplier = pd.read_csv(\"data/supplier_prices.csv\")\n",
    "compare = ...\n",
    "# переведите cost_new в числа и добавьте столбец cost_change"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "full"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_compare():\n",
    "    \"compare — товары с текущей и новой себестоимостью\"\n",
    "    assert isinstance(compare, pd.DataFrame), f\"compare — это {type(compare).__name__}, а нужна таблица\"\n",
    "    assert len(compare) == 20, f\"в compare {len(compare)} строк, а товаров 20\"\n",
    "    assert \"cost_x\" not in compare.columns, \"столбцы называются cost_x и cost_y: задайте свои окончания — вспомните параметр suffixes\"\n",
    "    assert \"cost_now\" in compare.columns and \"cost_new\" in compare.columns, f\"в compare нужны столбцы cost_now и cost_new, а сейчас {list(compare.columns)}\"\n",
    "    assert \"stock\" in compare.columns and \"name\" in compare.columns, \"из прайса поставщика нужны только product_id, cost и stock — тогда остальные столбцы не задвоятся\"\n",
    "    assert \"name_now\" not in compare.columns, \"задвоился столбец name: берите из supplier только нужные столбцы\"\n",
    "\n",
    "\n",
    "def test_change():\n",
    "    \"cost_new — числа, cost_change — на сколько выросла себестоимость\"\n",
    "    assert \"cost_new\" in compare.columns, \"в compare нет столбца cost_new — сначала исправьте то, о чём говорит проверка выше\"\n",
    "    assert compare[\"cost_new\"].dtype == float, \"cost_new пока текст: переведите его в числа, нераспознанное — в пропуски\"\n",
    "    assert compare[\"cost_new\"].isna().sum() == 3, \"в cost_new должно быть три пропуска — там, где поставщик написал «нет данных» или «—»\"\n",
    "    assert \"cost_change\" in compare.columns, \"в compare нет столбца cost_change\"\n",
    "    assert compare.loc[0, \"cost_change\"] == 50, f\"cost_change в первой строке — {compare.loc[0, 'cost_change']}: нужна новая себестоимость минус текущая\"\n",
    "\n",
    "_check(test_compare, test_change, names=[\"compare\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "full-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "supplier = pd.read_csv(\"data/supplier_prices.csv\")\n",
    "compare = products.merge(supplier[[\"product_id\", \"cost\", \"stock\"]], on=\"product_id\", suffixes=(\"_now\", \"_new\"))\n",
    "compare[\"cost_new\"] = pd.to_numeric(compare[\"cost_new\"], errors=\"coerce\")\n",
    "compare[\"cost_change\"] = compare[\"cost_new\"] - compare[\"cost_now\"]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "full-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Неверный ключ\n",
    "\n",
    "Главная ловушка `merge`. Возьмём очищенный журнал возвратов — 150 строк, по одной на возвращённую позицию:"
   ],
   "id": "text-51"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Возвраты: заказ, товар, сколько штук вернули\n",
    "returns = pd.read_csv(\"data/returns_raw.csv\").drop_duplicates()\n",
    "returns = returns.drop_duplicates(subset=[\"order_id\", \"product_id\"])[[\"order_id\", \"product_id\", \"quantity\"]]\n",
    "print(len(returns))\n",
    "returns.head(3)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "returns"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Хотим добавить к строкам заказов число возвращённых штук. В обеих таблицах есть столбец `quantity` —\n",
    "зададим окончания так, чтобы левый остался как есть, а правый стал `quantity_returned`: пустая строка\n",
    "в `suffixes` значит «название не менять». Попробуем соединить по номеру заказа:"
   ],
   "id": "text-57"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Соединение по order_id\n",
    "wrong = orders.merge(returns[[\"order_id\", \"quantity\"]], on=\"order_id\", how=\"left\", suffixes=(\"\", \"_returned\"))\n",
    "print(len(orders), len(wrong))\n",
    "print(wrong[\"quantity_returned\"].sum(), returns[\"quantity\"].sum())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "wrong-key"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Строк стало 2458 вместо 2448, а возвращённых штук — 402 вместо 214. Почти вдвое больше, чем на самом деле.\n",
    "Здесь сразу **две** ошибки, и у них одна причина: ключ `order_id` не определяет строку однозначно.\n",
    "\n",
    "**Первая: значение приклеилось не к той строке.** Посмотрим на заказ 10001:"
   ],
   "id": "text-63"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Заказ 10001 после соединения\n",
    "wrong.loc[wrong[\"order_id\"] == 10001, [\"order_id\", \"product_id\", \"quantity\", \"quantity_returned\"]]"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "wrong-look"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В заказе две позиции — кофемолка (P18) и травяной сбор (P09). Вернули только кофемолку, 2 штуки. Но соединение\n",
    "шло по номеру заказа, и возврат «приклеился» к **обеим** позициям: получилось, что вернули и сбор тоже.\n",
    "Число строк при этом не изменилось — такую ошибку по `len` не заметить. Её выдаёт только сверка сумм:\n",
    "402 против 214.\n",
    "\n",
    "**Вторая: строки размножились.** В заказе 10119 вернули оба товара — в журнале две записи с этим номером:"
   ],
   "id": "text-70"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Заказ 10119: две позиции и два возврата дали четыре строки\n",
    "print(returns[returns[\"order_id\"] == 10119])\n",
    "wrong.loc[wrong[\"order_id\"] == 10119, [\"order_id\", \"product_id\", \"quantity\", \"quantity_returned\"]]"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "wrong-dup"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`merge` соединяет каждую строку слева с **каждой** подходящей строкой справа. Две позиции заказа и две записи\n",
    "о возврате дали 2 × 2 = 4 строки. Если ключ повторяется с обеих сторон, строки перемножаются."
   ],
   "id": "text-79"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Вопрос.** ```python\n",
    "a = pd.DataFrame({\"k\": [\"x\", \"x\"], \"a\": [1, 2]})\n",
    "b = pd.DataFrame({\"k\": [\"x\", \"x\"], \"b\": [3, 4]})\n",
    "print(len(a.merge(b, on=\"k\")))\n",
    "```\n",
    "\n",
    "Что выведет этот код?\n",
    "\n",
    "- `2`\n",
    "- `4`\n",
    "- `1`\n",
    "\n",
    "<details><summary>Ответ</summary>\n",
    "\n",
    "`4`\n",
    "\n",
    "Каждая из двух строк `a` соединяется с каждой из двух строк `b` с тем же ключом: 2 × 2 = 4 строки.\n",
    "\n",
    "</details>"
   ],
   "id": "quiz-83"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Типичная ошибка: после merge строк стало больше — или суммы выросли.**\n",
    ">\n",
    "> Два признака неверного ключа. Строк после соединения больше, чем было в левой таблице, — ключ в правой таблице\n",
    "> повторяется. Сумма присоединённого столбца больше, чем в исходной таблице, — значения размножились по строкам.\n",
    "> Проверяйте оба: `len` до и после и суммы до и после."
   ],
   "id": "component-99"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Соединение по нескольким ключам\n",
    "\n",
    "Возврат относится не к заказу, а к **позиции** — паре «заказ, товар». Значит, и ключ составной: в `on`\n",
    "передают список столбцов, и строки сопоставляются, только если совпали оба."
   ],
   "id": "text-104"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Сколько вернули\n",
    "\n",
    "Соедините `orders` с `returns` по двум ключам так, чтобы остались все строки заказов, а столбцы количества\n",
    "назывались `quantity` и `quantity_returned`. Сохраните в `lines`. Пропуски в `quantity_returned` замените\n",
    "нулями и сделайте столбец целым. В `return_rate` сохраните долю возвращённых штук среди проданных.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`orders.merge(returns, on=[\"order_id\", \"product_id\"], how=\"left\", suffixes=(\"\", \"_returned\"))`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`lines[\"quantity_returned\"].sum() / lines[\"quantity\"].sum()`.\n",
    "\n",
    "</details>"
   ],
   "id": "returned-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "lines = ...\n",
    "# замените пропуски в lines[\"quantity_returned\"] нулями, тип — целый\n",
    "return_rate = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "returned"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_lines():\n",
    "    \"lines — строки заказов с числом возвращённых штук\"\n",
    "    assert isinstance(lines, pd.DataFrame), f\"lines — это {type(lines).__name__}, а нужна таблица\"\n",
    "    assert len(lines) != 2458, \"строк стало 2458 — больше, чем в orders: соединять нужно по двум ключам сразу\"\n",
    "    assert len(lines) != 150, \"осталось 150 строк — только позиции с возвратом: нужны все строки заказов — вспомните параметр how\"\n",
    "    assert len(lines) == 2448, f\"в lines {len(lines)} строк, а должно быть 2448 — столько же, сколько в orders\"\n",
    "    assert \"quantity_returned\" in lines.columns and \"quantity\" in lines.columns, f\"в lines нужны столбцы quantity и quantity_returned — вспомните параметр suffixes. Сейчас столбцы {list(lines.columns)}\"\n",
    "    assert lines[\"quantity_returned\"].isna().sum() == 0, \"в quantity_returned остались пропуски: позиция без возврата — это ноль возвращённых штук\"\n",
    "    assert str(lines[\"quantity_returned\"].dtype) in (\"int64\", \"int32\"), \"quantity_returned должен быть целым\"\n",
    "    assert lines[\"quantity_returned\"].sum() == 214, \"сумма quantity_returned не та: проверьте соединение\"\n",
    "\n",
    "\n",
    "def test_rate():\n",
    "    \"return_rate — доля возвращённых штук\"\n",
    "    assert abs(return_rate - 214 / 5210) < 1e-9, f\"return_rate = {return_rate!r} — это не доля возвращённых штук\"\n",
    "\n",
    "_check(test_lines, test_rate, names=[\"lines\",\"return_rate\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "returned-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "lines = orders.merge(returns, on=[\"order_id\", \"product_id\"], how=\"left\", suffixes=(\"\", \"_returned\"))\n",
    "lines[\"quantity_returned\"] = lines[\"quantity_returned\"].fillna(0).astype(\"int64\")\n",
    "return_rate = lines[\"quantity_returned\"].sum() / lines[\"quantity\"].sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "returned-solution"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Те же два заказа после соединения по двум ключам\n",
    "print(len(lines), lines[\"quantity_returned\"].sum())\n",
    "lines.loc[lines[\"order_id\"].isin([10001, 10119]), [\"order_id\", \"product_id\", \"quantity\", \"quantity_returned\"]]"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "fixed-look"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения упражнения выше:*\n",
    "\n",
    "Теперь всё сходится: 2448 строк, 214 возвращённых штук; в заказе 10001 возврат стоит только у кофемолки,\n",
    "в заказе 10119 — по одной штуке у каждого товара."
   ],
   "id": "component-121"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## validate: проверка при соединении\n",
    "\n",
    "Проверять число строк вручную легко забыть. Параметр `validate` делает проверку частью соединения: вы\n",
    "сообщаете, какое соединение ожидаете, а pandas останавливается с ошибкой, если это не так.\n",
    "\n",
    "- `\"many_to_one\"` — ключ в правой таблице уникален (заказы → справочник);\n",
    "- `\"one_to_one\"` — ключ уникален в обеих;\n",
    "- `\"one_to_many\"` — уникален в левой."
   ],
   "id": "text-125"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Заказы к справочнику: многие к одному\n",
    "checked = orders.merge(products, on=\"product_id\", validate=\"many_to_one\")\n",
    "len(checked)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "validate-ok"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "А соединение по одному `order_id` проверку не проходит — в правой таблице номер заказа повторяется:"
   ],
   "id": "text-136"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Ключ в правой таблице повторяется\n",
    "# Эта ячейка показывает ошибку MergeError: она перехвачена, чтобы «Выполнить всё» не останавливалось.\n",
    "try:\n",
    "    orders.merge(returns[[\"order_id\", \"quantity\"]], on=\"order_id\", how=\"left\", validate=\"many_to_one\")\n",
    "except Exception as error:\n",
    "    print(f\"{type(error).__name__}: {error}\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "validate-fail"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ошибка `MergeError` лучше, чем тихо завышенные числа: соединение не состоялось, а в сообщении перечислены\n",
    "ключи, которые повторяются в правой таблице. Но помните про первую ошибку из примера выше: `validate` ловит\n",
    "только повторы ключа. Значение, приклеившееся не к той строке, он не заметит — это видно лишь по суммам.\n",
    "\n",
    "## indicator: откуда строка\n",
    "\n",
    "Параметр `indicator=True` добавляет столбец `_merge`, в котором записано, нашлась ли пара: `both` — ключ есть\n",
    "в обеих таблицах, `left_only` — только в левой, `right_only` — только в правой."
   ],
   "id": "text-140"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Клиенты, у которых есть и нет заказов\n",
    "buyers = orders[[\"customer_id\"]].drop_duplicates()\n",
    "marked = customers.merge(buyers, on=\"customer_id\", how=\"left\", indicator=True)\n",
    "marked[\"_merge\"].value_counts()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "indicator"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "27 клиентов — `left_only`: в заказах их нет. Это те же «спящие» клиенты из прошлого урока, найденные без\n",
    "подсчёта заказов. `indicator` удобен для сверки двух списков: что есть в одном и отсутствует в другом."
   ],
   "id": "text-151"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Что возвращают чаще всего\n",
    "\n",
    "По таблице `lines` соберите `sold` — плоскую таблицу по товарам: `product_id`, `sold` (продано штук),\n",
    "`returned` (возвращено штук). Добавьте столбец `rate` — долю возврата, округлённую до трёх знаков. Затем\n",
    "присоедините названия товаров из `products` и сохраните в `rates`, отсортировав по убыванию `rate`.\n",
    "В `worst_name` сохраните название товара с наибольшей долей возврата.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`lines.groupby(\"product_id\", as_index=False).agg(sold=(\"quantity\", \"sum\"), returned=(\"quantity_returned\", \"sum\"))`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`sold.merge(products[[\"product_id\", \"name\"]], on=\"product_id\", validate=\"one_to_one\").sort_values(\"rate\", ascending=False)`.\n",
    "\n",
    "</details>"
   ],
   "id": "rates-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "sold = ...\n",
    "# добавьте в sold столбец rate\n",
    "rates = ...\n",
    "worst_name = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "rates"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_sold():\n",
    "    \"sold — продано и возвращено штук по товарам, доля возврата\"\n",
    "    assert isinstance(sold, pd.DataFrame), f\"sold — это {type(sold).__name__}, а нужна таблица\"\n",
    "    assert list(sold.columns)[:3] == [\"product_id\", \"sold\", \"returned\"], f\"столбцы сейчас {list(sold.columns)}, а первые три должны быть product_id, sold, returned\"\n",
    "    assert len(sold) == 20 and sold[\"sold\"].sum() == 5210 and sold[\"returned\"].sum() == 214, \"в sold 20 товаров\"\n",
    "    assert \"rate\" in sold.columns and abs(sold[\"rate\"].max() - 0.094) < 1e-9, \"rate не тот: нужна доля возврата с округлением до трёх знаков\"\n",
    "\n",
    "\n",
    "def test_rates():\n",
    "    \"rates — с названиями, по убыванию доли возврата; worst_name — товар с наибольшей долей\"\n",
    "    assert isinstance(rates, pd.DataFrame) and len(rates) == 20, \"rates — 20 строк: sold, соединённая с названиями товаров\"\n",
    "    assert \"name\" in rates.columns and \"rate\" in rates.columns, \"в rates нужны столбцы name и rate\"\n",
    "    assert rates[\"rate\"].tolist() == sorted(rates[\"rate\"].tolist(), reverse=True), \"отсортируйте rates по убыванию rate\"\n",
    "    assert worst_name == \"Кофемолка ручная\", f\"worst_name = {worst_name!r}, а чаще всего возвращают другой товар\"\n",
    "\n",
    "_check(test_sold, test_rates, names=[\"sold\",\"rates\",\"worst_name\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "rates-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "sold = lines.groupby(\"product_id\", as_index=False).agg(sold=(\"quantity\", \"sum\"), returned=(\"quantity_returned\", \"sum\"))\n",
    "sold[\"rate\"] = (sold[\"returned\"] / sold[\"sold\"]).round(3)\n",
    "rates = sold.merge(products[[\"product_id\", \"name\"]], on=\"product_id\", validate=\"one_to_one\").sort_values(\"rate\", ascending=False)\n",
    "worst_name = rates.iloc[0][\"name\"]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "rates-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итоги\n",
    "\n",
    "- Ключи с разными названиями — `left_on=` и `right_on=`.\n",
    "- Совпавшие названия столбцов получают окончания `_x` и `_y`; свои задаёт `suffixes=(\"_a\", \"_b\")`.\n",
    "- Несколько ключей — `on=[\"a\", \"b\"]`.\n",
    "- Неверный ключ размножает строки и значения: сверяйте `len` и суммы до и после, пишите\n",
    "  `validate=\"many_to_one\"`.\n",
    "- `indicator=True` показывает, у каких строк не нашлось пары."
   ],
   "id": "text-164"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/merge-keys](https://ds-edu-trainer.vercel.app/reference/pandas/merge-keys) · [pandas/merge](https://ds-edu-trainer.vercel.app/reference/pandas/merge) · [pandas/duplicates](https://ds-edu-trainer.vercel.app/reference/pandas/duplicates)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
