diff --git a/README.md b/README.md index 8408944..d141ee9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Note: all code examples presented here can be found in `notebooks/readme.ipynb` - Use a custom score function to grade the decision. - Directly specify the score manually and asynchronously. -The beauty of `learn_to_pick` is its flexibility. Whether you're a fan of VowpalWabbit or prefer PyTorch (coming soon), the library can seamlessly integrate with both, allowing them to be the brain behind your decisions. +The beauty of `learn_to_pick` is its flexibility. Whether you're a fan of VowpalWabbit or prefer PyTorch, the library can seamlessly integrate with both, allowing them to be the brain behind your decisions. ## Installation @@ -43,6 +43,8 @@ The `PickBest` scenario should be used when: - Only one option is optimal for a specific criteria or context - There exists a mechanism to provide feedback on the suitability of the chosen option for the specific criteria +### Scorer + Example usage with llm default scorer: ```python @@ -113,7 +115,46 @@ dummy_score = 1 picker.update_with_delayed_score(dummy_score, result) ``` -`PickBest` is highly configurable to work with a VowpalWabbit decision making policy, a PyTorch decision making policy (coming soon), or with a custom user defined decision making policy +### Using Pytorch policy + +Example usage with a Pytorch policy: +```python +from learn_to_pick import PyTorchPolicy + +pytorch_picker = learn_to_pick.PickBest.create( + policy=PyTorchPolicy(), selection_scorer=CustomSelectionScorer()) + +pytorch_picker.run( + pick = learn_to_pick.ToSelectFrom(["option1", "option2"]), + criteria = learn_to_pick.BasedOn("some criteria") +) +``` + +Example usage with a custom Pytorch policy: +You can alway create a custom Pytorch policy by implementing the Policy interface + +```python +class CustomPytorchPolicy(Policy): + def __init__(self, **kwargs: Any): + ... + + def predict(self, event: TEvent) -> Any: + ... + + def learn(self, event: TEvent) -> None: + ... + + def log(self, event: TEvent) -> None: + ... + + def save(self) -> None: + ... + +pytorch_picker = learn_to_pick.PickBest.create( + policy=CustomPytorchPolicy(), selection_scorer=CustomSelectionScorer()) +``` + +`PickBest` is highly configurable to work with a VowpalWabbit decision making policy, a PyTorch decision making policy, or with a custom user defined decision making policy The main thing that needs to be decided from the get-go is: @@ -134,7 +175,8 @@ In all three cases, when a score is calculated or provided, the decision making ## Example Notebooks - `readme.ipynb` showcases all examples shown in this README -- `news_recommendation.ipynb` showcases a personalization scenario where we have to pick articles for specific users +- `news_recommendation.ipynb` showcases a personalization scenario where we have to pick articles for specific users with VowpalWabbit policy +- `news_recommendation_pytorch.ipynb` showcases the same personalization scenario where we have to pick articles for specific users with Pytorch policy - `prompt_variable_injection.ipynb` showcases learned prompt variable injection and registering callback functionality ### Advanced Usage @@ -183,7 +225,7 @@ class CustomSelectionScorer(learn_to_pick.SelectionScorer): # inputs: the inputs to the picker in Dict[str, Any] format # picked: the selection that was made by the policy # event: metadata that can be used to determine the score if needed - + # scoring logic goes here dummy_score = 1.0 diff --git a/notebooks/news_recommendation_pytorch.ipynb b/notebooks/news_recommendation_pytorch.ipynb new file mode 100644 index 0000000..28b943b --- /dev/null +++ b/notebooks/news_recommendation_pytorch.ipynb @@ -0,0 +1,238 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "! pip install ../\n", + "# ! pip install matplotlib" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.0.1+cu117\n" + ] + } + ], + "source": [ + "import torch\n", + "print(torch.__version__)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is an example of a news recommendation system. We have two users `Tom` and `Anna`, and some article topics that we want to recommend to them.\n", + "\n", + "The users come to the news site in the moring and in the afternoon and we want to learn what topic to recommend to which user at which time of day.\n", + "\n", + "- The action space here are the `article` topics\n", + "- The criteria/context are the user and the time of day\n", + "- The score is whether the user liked or didn't like the recommendation (simulated in the `CustomSelectionScorer`)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "\n", + "users = [\"Tom\", \"Anna\"]\n", + "times_of_day = [\"morning\", \"afternoon\"]\n", + "articles = [\"politics\", \"sports\", \"music\", \"food\", \"finance\", \"health\", \"camping\"]\n", + "\n", + "def choose_user(users):\n", + " return random.choice(users)\n", + "\n", + "\n", + "def choose_time_of_day(times_of_day):\n", + " return random.choice(times_of_day)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "import learn_to_pick\n", + "\n", + "class CustomSelectionScorer(learn_to_pick.SelectionScorer):\n", + " def get_score(self, user, time_of_day, article):\n", + " preferences = {\n", + " 'Tom': {\n", + " 'morning': 'politics',\n", + " 'afternoon': 'music'\n", + " },\n", + " 'Anna': {\n", + " 'morning': 'sports',\n", + " 'afternoon': 'politics'\n", + " }\n", + " }\n", + "\n", + " # if the article was the one the user prefered for this time of day, return 1.0\n", + " # if it was a different article return 0.0\n", + " return int(preferences[user][time_of_day] == article)\n", + "\n", + " def score_response(\n", + " self, inputs, picked, event: learn_to_pick.PickBestEvent\n", + " ) -> float:\n", + " chosen_article = picked[\"article\"]\n", + " user = event.based_on[\"user\"]\n", + " time_of_day = event.based_on[\"time_of_day\"]\n", + " score = self.get_score(user, time_of_day, chosen_article)\n", + " return score" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Initializing two pickers, one with the default decision making policy `picker` and one with a random decision making policy `random_picker`.\n", + "\n", + "Both pickers are initialized with the `CustomSelectionScorer` and with `metrics_step` and `metrics_window` in order to keep track of how the score evolves in a rolling window average fashion." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Device: cuda\n" + ] + } + ], + "source": [ + "from learn_to_pick import PyTorchPolicy\n", + "\n", + "pytorch_picker = learn_to_pick.PickBest.create(\n", + " metrics_step=100, metrics_window_size=100, policy=PyTorchPolicy(), selection_scorer=CustomSelectionScorer())\n", + "random_picker = learn_to_pick.PickBest.create(\n", + " metrics_step=100, metrics_window_size=100, policy=learn_to_pick.PickBestRandomPolicy(), selection_scorer=CustomSelectionScorer())" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "# randomly pick users and times of day\n", + "\n", + "for i in range(2500):\n", + " user = choose_user(users)\n", + " time_of_day = choose_time_of_day(times_of_day)\n", + "\n", + " random_picker.run(\n", + " article = learn_to_pick.ToSelectFrom(articles),\n", + " user = learn_to_pick.BasedOn(user),\n", + " time_of_day = learn_to_pick.BasedOn(time_of_day),\n", + " )\n", + "\n", + " pytorch_picker.run(\n", + " article = learn_to_pick.ToSelectFrom(articles),\n", + " user = learn_to_pick.BasedOn(user),\n", + " time_of_day = learn_to_pick.BasedOn(time_of_day),\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Plot the score evolution for the default picker and the random picker. We should observe the default picker to **learn** to make good suggestions over time." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The final average score for the default policy, calculated over a rolling window, is: 0.93\n", + "The final average score for the random policy, calculated over a rolling window, is: 0.53\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGdCAYAAADAAnMpAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAABwzUlEQVR4nO3dd3xT5ffA8U/S3dJBKV1QKHvvUYYsqYIDRUUQFRARBcGFEweoPxW3fBUVRRkOFEHBgYKIDIHK3puW3U2heyb398dt0hZL27RJblLO+/Xqq2l6c+9pKM3J85znPDpFURSEEEIIITSi1zoAIYQQQlzdJBkRQgghhKYkGRFCCCGEpiQZEUIIIYSmJBkRQgghhKYkGRFCCCGEpiQZEUIIIYSmJBkRQgghhKZctQ6gKoxGI/Hx8fj6+qLT6bQORwghhBBVoCgKmZmZhIeHo9dfefzDKZKR+Ph4IiIitA5DCCGEENVw9uxZGjZseMXvO0Uy4uvrC6g/jJ+fn8bRCCGEEKIqMjIyiIiIML+OX4lTJCOmqRk/Pz9JRoQQQggnU1mJhRSwCiGEEEJTkowIIYQQQlOSjAghhBBCU05RM1IVBoOBwsJCrcMQpbi4uODq6irLsYUQQlSoViQjWVlZnDt3DkVRtA5FXMbb25uwsDDc3d21DkUIIYSDcvpkxGAwcO7cOby9valfv768C3cQiqJQUFBASkoKJ0+epEWLFhU2vBFCCHH1cvpkpLCwEEVRqF+/Pl5eXlqHI0rx8vLCzc2N06dPU1BQgKenp9YhCSGEcEC15q2qjIg4JhkNEUIIURl5pRBCCCGEpixORjZu3MiwYcMIDw9Hp9OxYsWKSh+zfv16unbtioeHB82bN2fhwoXVCFUIIYQQtZHFyUh2djadOnXi448/rtLxJ0+e5KabbmLQoEHs2bOHxx9/nAceeIDVq1dbHKyonvvuu4/hw4drHYYQQghRLosLWG+44QZuuOGGKh8/d+5cmjRpwnvvvQdAmzZt2LRpEx988AFDhgyx9PJCCCGEqGVsXjMSExNDdHR0mfuGDBlCTEzMFR+Tn59PRkZGmY/arqCgQOsQhBDO6OIp2PwhxP6tdSRCVJvNk5HExERCQkLK3BcSEkJGRga5ubnlPmbWrFn4+/ubPyIiIqp8PUVRyCko0uTDkqZrAwcOZOrUqTz++OMEBQUxZMgQ3n//fTp06ICPjw8RERE8/PDDZGVlmR+zcOFCAgICWL16NW3atKFOnToMHTqUhIQE8zEGg4Fp06YREBBAvXr1eOaZZ/4TV35+Po8++ijBwcF4enpyzTXXsH37dvP3169fj06nY/Xq1XTp0gUvLy+uvfZakpOT+eOPP2jTpg1+fn7cfffd5OTkVPlnFkJYSX4m7P4GFtwE/+sEa16CxXfBpbNaRyZEtThkn5Hp06czbdo089cZGRlVTkhyCw20naFNPcqhV4fg7V71p3TRokVMnjyZzZs3A/DHH3/w4Ycf0qRJE+Li4nj44Yd55pln+OSTT8yPycnJ4d133+Xrr79Gr9dz77338tRTT/Htt98C8N5777Fw4ULmz59PmzZteO+991i+fDnXXnut+RzPPPMMP/74I4sWLaJx48a8/fbbDBkyhBMnThAYGGg+7uWXX2bOnDl4e3szcuRIRo4ciYeHB4sXLyYrK4vbbruNjz76iGeffbamT50QojJGI5z6B/YshsO/QKHpjYAOvAIg9yKsfxOGV62eTwhHYvNkJDQ0lKSkpDL3JSUl4efnd8UmZR4eHnh4eNg6NM21aNGCt99+2/x1q1atzLcjIyN57bXXmDRpUplkpLCwkLlz59KsWTMApk6dyquvvmr+/uzZs5k+fTq33347oNbslC4Wzs7O5tNPP2XhwoXm2p958+axZs0avvzyS55++mnzsa+99hp9+/YFYMKECUyfPp3Y2FiaNm0KwIgRI1i3bp0kI0LY0oVYNQHZtwTSS4181GsOnUZDp7sgMxG+GAx7F0OfqRDcRrt4hagGmycjvXv35vfffy9z35o1a+jdu7dNrufl5sKhV7UpjPVyc7Ho+G7dupX5+q+//mLWrFkcOXKEjIwMioqKyMvLIycnB29vb0Dd68WUiACEhYWRnJwMQHp6OgkJCURFRZm/7+rqSvfu3c1TNbGxsRQWFpqTDAA3Nzd69uzJ4cOHy8TTsWNH8+2QkBC8vb3NiYjpvm3btln0MwshqiAvHQ4uV5OQs1tL7vfwh/a3Q+d7oGF3MDV79G8IbYbB4V9h7asw+jtt4haimixORrKysjhx4oT565MnT7Jnzx4CAwNp1KgR06dP5/z583z11VcATJo0iTlz5vDMM89w//338/fff/PDDz+wcuVK6/0Upeh0OoumSrTk4+Njvn3q1CluvvlmJk+ezOuvv05gYCCbNm1iwoQJFBQUmJMRNze3MufQ6XQ22yCw9LV0Ol251zYajTa5thBXHaMB4tapCciRlVCUp96v00OzwdB5NLS6CdyusK3CtTPUxx39Hc78C4162S92IWrI4gLWHTt20KVLF7p06QLAtGnT6NKlCzNmzAAgISGBM2fOmI9v0qQJK1euZM2aNXTq1In33nuPL774Qpb1Xmbnzp0YjUbee+89evXqRcuWLYmPj7foHP7+/oSFhbF1a8k7qaKiInbu3Gn+ulmzZri7u5vrVECd+tm+fTtt27at+Q8ihLBMylFYMxM+aAff3AEHflQTkfqt4bpX4YlDcO8yaH/HlRMRgPotocsY9faamSC7mAsnYvEQwsCBAyt8J15ed9WBAweye/duSy91VWnevDmFhYV89NFHDBs2jM2bNzN37lyLz/PYY4/x5ptv0qJFC1q3bs3777/PpUuXzN/38fFh8uTJPP300+bRrLfffpucnBwmTJhgxZ9ICFGh/ExYci/ErS+5z6sudLhTrQUJ71IyDVNVA59Ta0vO/gvHVkGrqveEEkJLzjGfcRXo1KkT77//Pm+99RbTp0+nf//+zJo1i7Fjx1p0nieffJKEhATGjRuHXq/n/vvv57bbbiM9Pd18zJtvvonRaGTMmDFkZmbSvXt3Vq9eTd26da39YwkhrmT9m2oionOBFtdD57uh5RBwrUHxvl849JoMmz6Av15Rz6u3rJZNCC3oFFsVHFhRRkYG/v7+pKen4+fnV+Z7eXl5nDx5kiZNmsgW9Q5I/n2EKEfyEZjbF4xFcPdSaHm99c6de0ntPZJ3CW79BLrcY71zC2Ghil6/S5Nde4UQwp4UBX5/Sk1EWt1k3UQE1J4j/Yr7NK17AwrzrHt+IWxAkhEhhLCng8vV5mWunjD0Ddtco+eD4NcAMs7B9nm2uYYQViTJiBBC2Et+Fqx+Qb19zRNQN9I213HzgoHT1dv/vKdO3QjhwKSAVQhx9SjMBRcP0Gv0PmzjO5AZDwGNoe9jtr1Wp9EQMwdSjsDm/0H0TNtezxYK8yArqfLjqsI3tGbFwcKmJBkRQlwd0uLg077QqDfc/QO42PnPX+pxiCneN+aGt9TRC1tycYXBM+D7u+HfT4unbsJse01rKcqHbfNg49tqN1pr8K4Hwz9VVywJhyPTNEKIq8PB5ermcrFr4e//s++1FQX+eAaMhepy25ZD7XPdVjdCRBQU5cKGt+xzzZpQFDi4Aj7uCX++oCYiLu7g6lWzDxd3yLkAi0fCny+CoVDrn1RcRkZGhBBXh+N/ldzePFt9kW59o32uffhXiP1bfVEc+qblzcyqS6eD6JdhwQ2w6yvoPQWCWtjn2pY6t0Otpzn7r/p1nRC49kV1H56a9kopyoc1M2DrXNjykdouf8R8CGhU87iFVUgyIoSo/fLSSzacazscDq2A5ZPgoQ0Q2MS21y7IgdXPq7f7Pgb1mlV8vLU17qOOxBxbpY4IjfzKvtevzKUzaoO2A8vUr129oO+j0OdR8KhjnWu4eqhTY5HXwM9T4Nx2mHuNOm3T+ibrXKOqslPVouLzu6xzvjr11a0CWt5Q8XYBDk6SEVGpl19+mRUrVrBnzx6tQxGieuI2gGKAes3hji8gM0FNTn4YCxPW2PaP+Kb3If0s+EfANdNsd52KDJ4Jx1bDoZ/h3E5o2K3yx9haXjr8875az2LIB3RqF9prX1Q7ydpCm2EQ2hGWjYfzO9V6mqjJ6h5Aru62uaZJYR5s/VT9mfMzrHvuw7+CZ4CalHS+Bxp0td/om5VIMuKk7rvvPi5dusSKFSu0DkUIx3dijfq5+XXg4gYjFsBn/SBxn1rLccuHtrnuhVh1JQvAkDfA3ds216lMSFt1dc3exfDXTBj3q3YvVoYi2LkA1s9S6zgAmvSH61+DsE62v37dxjB+Fax9RV1ttPVTdWpoxALbjJIpirr54V+vQHrxJrJhnaDXFCsUMSsQv0fdjyjjPOz4Uv0Iaqkmdh1H2S6xszJJRq5yBQUFuLvb+B2BEFpSFDixVr3dPFr97N9AHSH5+nbYtQga9VL/eFvbqulgKIBm16rvyrU0aLo6FXLqH7WI1/Rc2IuiqKMza16C1GPqffVawPX/p04j2TM5cnWHIa+r0zYrJkP8bvisP9zyEbQbbr3rnPlXnaI7X7xzum+4usKp4yjrLS9ve6s6mnRyA+z5Th0lST0Gf70Ma1+FpgPV0ZLWN9l+BVcNyGoajQwcOJCpU6cydepU/P39CQoK4qWXXkJRFF599VXat2//n8d07tyZl156iZdffplFixbx888/o9Pp0Ol0rF+/HoD9+/dz7bXX4uXlRb169XjwwQfJysoyn+O+++5j+PDhvP7664SHh9OqVSsAzp07x+jRowkMDMTHx4fu3buzdevWMtf/+uuviYyMxN/fn7vuuovMzEzbPUFCWEvKEfVdo6snRPYtub/ZtTCouJbjt2mQdNC61z36BxxfDXo3uOFt7YfNAxqpy3sB1rwMRqP9rp2wD766Bb4bpb5QeteDG9+Fh2PUnYW1em5a3QCTNkFEL3XqZOk4WPlkzVvop8WpU4Dzh6iJiJsPDHoRHtkJnUdbv8+N3kX9fb5jHjx1TE2qGvUBxagWTv84Ad5tCb88qiZIDrglXe0bGVEUdfmeFty8LfpPtWjRIiZMmMC2bdvYsWMHDz74II0aNeL+++/nlVdeYfv27fTo0QOA3bt3s2/fPn766SeCg4M5fPgwGRkZLFiwAIDAwECys7MZMmQIvXv3Zvv27SQnJ/PAAw8wdepUFi5caL7u2rVr8fPzY80adeg6KyuLAQMG0KBBA3755RdCQ0PZtWsXxlJ/rGJjY1mxYgW//fYbFy9eZOTIkbz55pu8/vrrVnjihLCh48VTNJHX/PedYb+n1D/OsWthyRh4cD14XnkzryorzIU/nlVvO9IKln5PqqtqkvarUwcd77Tt9TLi4e/XYM9iQFEbzvWarO6d4+lv22tXlX9DuO83WPe6utvx9i/g7Da4c6Hlxca5F2Hju7D1M3UZt04PXcbAoBfAN8Qm4f+Hpx90Hat+pMXB3u9h73dqofCuRepHYFPodDd0GuUwK4pqXzJSmANvaDRH9nw8uPtU+fCIiAg++OADdDodrVq1Yv/+/XzwwQdMnDiRIUOGsGDBAnMysmDBAgYMGEDTpk0B8PLyIj8/n9DQUPP5Fi1aRF5eHl999RU+Pmocc+bMYdiwYbz11luEhKj/GXx8fPjiiy/M0zOff/45KSkpbN++ncDAQACaN29eJlaj0cjChQvx9fUFYMyYMaxdu1aSEeH4ThQv6S1vWkKvh9vnqUP0abHwy1S4c1HN36lv/hAunVb3h+n/dM3OZU3egeqKnr//T/1oe6ttCjcLstXnYMuHJW8O29+hFtLWbWz969WUi5u6BLrxNbD8QbWW6LP+MOx/0GFE5Y8vKlBrNTa8pSYkAM0Gq3UwIW1tGnqFApuqo38DnoPTm9Wk5OAKNUlZ95r60aS/mpi0vcWi1y9rk2kaDfXq1QtdqT96vXv35vjx4xgMBiZOnMh3331HXl4eBQUFLF68mPvvv7/C8x0+fJhOnTqZExGAvn37YjQaOXr0qPm+Dh06lKkT2bNnD126dDEnIuWJjIw0JyIAYWFhJCcnW/TzCmF3+VlwJka9faUaCZ96MHKROp1y6Ge1F0VNXDylrqAB9cXIWstTraXXZLWHx6XTaiGpNRkNsOtr+LArbHhTTUQiouCBtWpfD0dMREprEa1O2zTuCwVZ6vTGr4+pI13lURS1RuOTKFj1nJqIBLeFe3+EMT9pm4iUptdDk34w/BN1Gmf4XDUJATi5EVZMgndawNFVmoVY+0ZG3LzVEQqtrm0lw4YNw8PDg+XLl+Pu7k5hYSEjRlQhQ6+C0skKqKMslXFzcyvztU6nKzONI4RDOvWPWkAa0Fhd1nslDburq13+eFrt0BneFRpFVe+aq56Hojz1j32726p3Dlty94EBz8LKabDhbbVw18O38sdVJnad+twlHVC/rhsJ0a+ooy9a18tYwi8cxv6ijnJsfAd2LoSz29Vpm/otS447v0v9eU9vVr/2CYZrX4DO99p/qwFLeNRR61Y6j1anbvYuUVdZpZ2E0A6aheXAz1g16XSaDjVZ4vIC0X///ZcWLVrg4qJ2Gxw3bhwLFizA3d2du+66q0zS4O7ujsFgKPP4Nm3asHDhQrKzs80Jx+bNm9Hr9eZC1fJ07NiRL774grS0tApHR4RwOqWnaCp7Qew5UV3ieeBHWHofTPoHfIIsu97xNXB0Jehd4YZ3HPdFuOtYdZ+ctFjYMkddaVNdyUfUFTLH/1S/9vSH/s+oz6ezbkzn4qomFo37wE8PQvJB+Hwg3Py+Omqy9lXY/4N6rKsX9JmqTn9ZI6mzp4BGMOBp6P8UJB9WV5lpRKZpNHTmzBmmTZvG0aNH+e677/joo4947LGSnTwfeOAB/v77b1atWvWfKZrIyEj27dvH0aNHSU1NpbCwkHvuuQdPT0/GjRvHgQMHWLduHY888ghjxowx14uUZ/To0YSGhjJ8+HA2b95MXFwcP/74IzExMTb72YWwOUUpKV6tyjJWnU6tEQhqqe6s++MEddqhqory1Z4lAFGTILi15THbi4sbDH5JvR0zB7KqMeWalQK/PQGf9lETEb2r2kDs0T3qi7OzJiKlNRukTts0GQCF2bD8Ifiwc0ki0mm0ukLm2hedLxEpTafTfEpJkhENjR07ltzcXHr27MmUKVN47LHHePDBB83fb9GiBX369KF169ZERZUdMp44cSKtWrWie/fu1K9fn82bN+Pt7c3q1atJS0ujR48ejBgxgsGDBzNnzpwK43B3d+fPP/8kODiYG2+8kQ4dOvDmm2+aR2iEcEppcWpdhN6tZH68Mh6+art0N2+IW2/Z5nIxc9Rr1glRp0EcXdvh6nRUQZY6HVFVhblqO/MPu8CO+Wpn29Y3w5RtcMObapFsbeIbAmOWqytidHowFkFkP3hwA9w2V9PRhNpEpygOuOD4MhkZGfj7+5Oeno6fX9lld3l5eZw8eZImTZrg6ek8ffkHDhxI586dmT179hWPURSFFi1a8PDDDzNtmkZtpGvIWf99RC3w71xY9ayaiIz71bLH7vsBfpoI6OCeZWphY0UunVV3mi3MUVfndBxZ7bDtKm6D2v9D7wZTt6mrL67EaFSbpq19VW1vDxDWWa21Kd2/pTZL2Ku2sY/s57hTcA6motfv0mRkxEGlpKQwZ84cEhMTGT9+vNbhCOF8KlrSW5mOI6H7BECBnx5Qk42K/PmCmog06gMdbNy7w5qaDlCXoBoL4e8Klumf3gJfDFYTtPSz4NcQbvscJq67ehIRUNu4N+kviYgN1L4C1loiODiYoKAgPv/8c+rWrat1OEI4l8JcOLVJvd38uuqdY+gsiN+ltgpfOk7dz6S8nhyx69QlwToXuNGBi1avJHqm2vTtwDJ1t9zS+8NciIU1M+DIb+rX7nXgmifURm4O3FpcOB9JRjRiat9+JU4weyaE4zq9GYpy1b1AgttU7xyuHmoDtM/6qy29/3wRbny77DFFBSVFqz0nQuh/t3FweGGdoP0INRn56xW1P0ZOmlpHsm1eSSfRruPUBlp1grWOWNRCMk0jhKh9zBvjDa7ZSEXdxnD75+rtbZ/B/mVlv7/1U3WvFZ/6MLAGy2O1du2Lat1I7FpY+ZRanPrvJ2oi0vw6mLwFhs2WRETYjCQjQojax1Qv0qKaUzSltRyi7ukC6kZjKcXdjDPi1aZhoDb38gqo+bW0EtgEuhfXpm2fB3mXILiduork3mXVH10SoopqTTIi0xqOSf5dhN1dPK2OVuhc1P4Q1jDweXUFRWG2uhtrQTb8+ZK6LLZhT7XfhLPr/4y6l06dEHXX10n/qDvBCmEHTl8zYuqFUVBQUKW25sK+cnLUTbIubycvhM2YRkUielpvtMLFVd1bZW4/SDkCX98GZ7cCOrjpXetvCa+FOvXh0d3qdE1t+HmEU3H6ZMTV1RVvb29SUlJwc3NDL/+JHIKiKOTk5JCcnExAQIA0UBP2Y64XqcaS3orUCYY7F8DCm4sTEaD7/WVXnzi72tA1VTglp09GdDodYWFhnDx5ktOnT2sdjrhMQEAAoaGhWochrhZFBXByg3rb2skIqHuVRL+s7sXiFagWfgohaszpkxFQ25m3aNGCgoICrUMRpbi5ucmIiLCvs/+qdRw+9SG0o22u0ecRtQV4UKva1/pcCI3UimQEQK/XS7txIa52pbuu2mrKVqeD9nfY5txCXKWkwEIIUXscr0ELeCGEZiQZEULUDhnxkHwQ0EHTQVpHI4SwgCQjQojawbSKpkE38KmnbSxCCItIMiKEqB1OrFE/yxSNEE5HkhEhhPMzFEHsevW2JCNCOB1JRoQQzu/8DshPB6+60KCr1tEIISwkyYgQwvmZlvQ2uxb00ttGCGcjyYgQwvkdl3oRIZyZJCNCCOeWlQIJe9TbzQZrGooQonokGRFCOLfYv9XPoR3BN0TbWIQQ1SLJiBDCucmSXiGcniQjQgjnZTSUNDtrcZ22sQghqk2SESGE80rYA7lp4OEHDXtoHY0QopokGRFCOC/TxnhNB4CLm7axCCGqTZIRIYTzMvUXaS5TNEI4M0lGhBDOKSdN7bwK0FyW9ArhzCQZEUI4p7h1oBihfhvwb6h1NEKIGpBkRAjhnMyraGRJrxDOTpIRIYTzUZRS9SKSjAjh7CQZEULYTlGBbc6buB+yksDNBxr1ts01hBB2I8mIEMI2EvbBmxHw3d1QkGPdc5tGRZr0B1cP655bCGF3kowIIWxj+xdQlAdHV8I3d0BeuvXObaoXkVU0QtQKkowIIayvKB8OrVBvu7jDmS2w8GZ1h92aysuAs/+qt6VeRIhaQZIRIYT1Hf9THQnxDYcJa8CnPiTugwVD4dLZmp375AYwFkG95hDYxDrxCiE0Va1k5OOPPyYyMhJPT0+ioqLYtm1bhcfPnj2bVq1a4eXlRUREBE888QR5eXnVClgI4QT2/aB+7nAHhHeG8avAPwIunID5QyH1ePXPLatohKh1LE5GlixZwrRp05g5cya7du2iU6dODBkyhOTk5HKPX7x4Mc899xwzZ87k8OHDfPnllyxZsoTnn3++xsELIRxQ7iU4tkq93XGU+jmoOdy/GoJaQsY5mD8E4vdYfm5FKVUvIsmIELWFxcnI+++/z8SJExk/fjxt27Zl7ty5eHt7M3/+/HKP37JlC3379uXuu+8mMjKS66+/ntGjR1c6miKEcFKHfgZDAQS3hZD2Jff7N1BHSMI6Q84FtYbk1GbLzp1yFNLPgqsnRF5j1bCFENqxKBkpKChg586dREeXvCPR6/VER0cTExNT7mP69OnDzp07zclHXFwcv//+OzfeeOMVr5Ofn09GRkaZDyGEk9i/VP3c4U7Q6cp+z6cejPsVGl8DBZnwze1wbHXVz22aomncF9y8rBOvEEJzFiUjqampGAwGQkJCytwfEhJCYmJiuY+5++67efXVV7nmmmtwc3OjWbNmDBw4sMJpmlmzZuHv72/+iIiIsCRMIYRW0s/BqX/U2x3uLP8YTz+4dxm0vEFd+vv93bBvadXOL/UiQtRKNl9Ns379et544w0++eQTdu3axU8//cTKlSv5v//7vys+Zvr06aSnp5s/zp6tYfW9EMI+9i9TPzfuCwEVvIlw84JRX6s1JcYi+GkibJtX8bkLsuF08bROi+usE68QwiG4WnJwUFAQLi4uJCUllbk/KSmJ0NDQch/z0ksvMWbMGB544AEAOnToQHZ2Ng8++CAvvPACev1/8yEPDw88PKSrohBOx7SKpuPIyo91cYPhc8HTH7Z9Dr8/BXmXoN9T/53eATi1Sa1FCWikLusVQtQaFo2MuLu7061bN9auXWu+z2g0snbtWnr3Ln9/iJycnP8kHC4uLgAoimJpvEIIR5V4AJIPqk3O2t5atcfo9XDD2zDgWfXrv1+DP19UV81crvQUTXnJihDCaVk0MgIwbdo0xo0bR/fu3enZsyezZ88mOzub8ePHAzB27FgaNGjArFmzABg2bBjvv/8+Xbp0ISoqihMnTvDSSy8xbNgwc1IihKgF9hePirS4HrzqVv1xOh0Meh48A2D1dIiZo46Q3Pw/cCn1J+r4GvVzc5miEaK2sTgZGTVqFCkpKcyYMYPExEQ6d+7MqlWrzEWtZ86cKTMS8uKLL6LT6XjxxRc5f/489evXZ9iwYbz++uvW+ymEENoyGkuKUE29RSzV+2F1yuaXqbD7G7WD6x1fqhvhXYiFiydB7wZN+lkvbiGEQ9ApTjBXkpGRgb+/P+np6fj5+WkdjhDicic3wqJhajLx5DFw86z+uQ7/CsvuV+tDmg6EUd/CnsXwx9MQ2Q/u+81qYQshbKuqr9+yN40QouZMhattb61ZIgLQZhjcsxTcfCBuPXx1a8mme7KKRohaSZIRIbSmKGrxp6FI60iqpzBP7boK1Z+iuVzTgTDuF7WO5PyOkiW90l9EiFpJkhEhtHZwOcztq64icUbHV0N+Bvg1hEZ9rHfeht1h/B9Qp7htgG+42mJeCFHrSDIihNbi1qmfd38N+VnaxlId5h16R6hLda0ppC1MWA2tboLrXpElvULUUpKMCKG1xAPq54KskukOZ5GTVrK3jLWmaC5XNxJGL65aIzUhhFOSZEQILRmKIPlQyde7v9Euluo49DMYC9XdeUNkCkUIUT2SjAihpbRYdbM4V0/Q6eHMFkg9oXVUVWdJ+3chhLgCSUaE0FLifvVzaMeSzqJ7nGR05NIZNXlCB+1HaB2NEMKJSTIihJbMyUh76HKvenvPd86xzHd/ccfVyGvAv4G2sQghnJokI0JoKam4eDWkPbQcCt71ICsRYtdW/DitKUqpKRobFa4KIa4akowIoaXS0zSu7tDxLvXr3V9rF1NVJO6HlCPg4gFtb9E6GiGEk5NkRAitZCVDVhKgK1mJYpqqOfoHZKdqFlql9i1RP7caqu5HI4QQNSDJiBBaMY2K1GsG7j7q7ZC2EN4VjEUlL/iOxmiAAz+qt2WKRghhBZKMCKGV0vUipXUdo37e9bVam+FoTv0DmQnqvjHNZeM6IUTNSTIihFZMnVdDL0tG2t+h9h1JOQznd9k/rsqYClfb3abWuQghRA1JMiKEVkoXr5bm6Q9tb1VvO1oha2EuHPpFvS2NzoQQViLJiBBaKMyD1GPq7cunaaCkkPXAj1CQY7+4KnP0DyjIBP9GENFL62iEELWEJCNCaCHlMCgG8AoEv/D/fr/xNRDQGPIz4PCv9o/vSkyNzjreaf0deoUQVy35ayKEFkrXi+h0//2+Xl8yOuIoUzXZF+D4n+rtDjJFI4SwHklGhNCCeSVNhysf0/luQKeuXkmLs0tYFTq0XF1yHNoRgltrHY0QohaRZEQILZiLVytIRvwbQrNr1dt7Fts+psrsM03RyKiIEMK6JBkRwt4U5crLei9n3jxvsdpsTCsXT8HZf5EdeoUQtiDJiBD2ln4W8tNB7wZBrSo+tvVN4FUXMs5D3Dr7xFceU+Fq0wHgF6ZdHEKIWkmSESHszTRFU7915U3DXD1KikV3f2PbuK6k9A69UrgqhLABSUaEsLeqTtGYmKZqjqyEnDTbxFSRhD1qTxRXT2gzzP7XF0LUepKMCGFvifvUzxUVr5YW1hHCOoGhoGSEwp5MhautbgRPP/tfXwhR60kyIoS9XWmDvIp0Kd48b7edN88zFMGBZeptWUUjhLARSUaEsKe8DHVlClR9ZATUzfNcPNREJmGvTUIr18kNkJWkdoptNth+1xVCXFUkGRHCnpIOqp/9GoB3YNUf5x0IbW5Wb9uzkNW0ikZ26BVC2JAkI0LYU3WmaExMhaz7f1B3z7W1gpySfXE6jrL99YQQVy1JRoSwJ0uLV0trMgD8IyAvXV1ZY2tHf4eCLHXDvoietr+eEOKqJcmIEPZk6bLe0vQuxfvVYJ/N80wrdzqOLH8zPyGEsBJJRoSwF0MRJB9Sb1e0QV5FOt+jfo7bABdPWyeu8mSnwom/1NvS6EwIYWOSjAhhL2mxUJQHbj4Q2KR656jbWJ2uQYG931k1vDIOLgfFAGGdoX5L211HCCGQZEQI+zG1gQ9pq065VJe558i3YDTWPK7y7FuifpbCVSGEHUgyIoS9mJORatSLlNbmZvDwh/Qzah8Qa0s8AOe2g06v9jcRQggbk2RECHsxLeutzkqa0ty8oMMI9ba1e44cXA4LblBvN48G3xDrnl8IIcohyYgQ9pJopWQESnqOHP4Vci/W/HyFefDbNFh6H+RnQEQvGPZhzc8rhBBVIMmIEPaQlQJZiYAOgtvW/HzhXdTpHkM+7F9Ws3NdiIUvo2HHl+rX10yD+1aCX1jN4xRCiCqQZEQIe0gqrhcJbAoedWp+Pp2uZHSkJlM1+5fBZ/3VehbvILj3R4ieCS6uNY9RCCGqSJIRIeyhJs3OrqTDSNC7QcKekuLYqirMhV8egR8nqF1WI/vBpE1qnYgQQtiZJCNC2IMpWbBGvYiJTz1ofaN625LRkZSjMO9a2PUVoIMBz8LYn2VaRgihGUlGhLAH8wZ5VkxGoKTnyL4lUJRf+fF7FsPnA9VOsD7BMHYFDHq+Zn1PhBCihiQZEcLWCvPU0Qiw7sgIQLNrwTdcXVFz9PcrH1eQDcsnw4rJUJijdnGdtAmaDrRuPEIIUQ2SjAhhaylH1NbqXnXBL9y65y6zed4VpmqSDsHng2DvYrWR2aAXYcxy6SEihHAYkowIYWvmKZr2ttn91pSMnFgL6edK7lcUtS5k3iBIPQq+YTDuVxjwtEzLCCEciiQjQtiauXi1o23OX68ZNL4GUGBP8eZ5+Znw04PqipmiPHWVzKRNEHmNbWIQQogakGRECFuzxbLey5l6juz5BhL2qkWq+38AnQtEvwx3LwWfINtdXwghakCSESFsSVFss6z3cm1vAXdfuHhKrQ+5cAL8GsD43+GaJ0Av/9WFEI5L/kIJYUvpZyE/XW1OFtTKdtdx94H2t6u3FQO0HKpOyzTqZbtrCiGElUjPZyFsyTRFU78VuLrb9lr9n4LMBHW5b9Qk2xTLCiGEDUgyIoQt2WOKxiSgEdyz1PbXEUIIK5NpGiFsybRBXogNi1eFEMLJSTIihC2ZR0YkGRFCiCuRZEQIW8nLUFe3gPX3pBFCiFpEkhEhbCX5kPrZN1zdYVcIIUS5qpWMfPzxx0RGRuLp6UlUVBTbtm2r8PhLly4xZcoUwsLC8PDwoGXLlvz+ewWbeglRG9izeFUIIZyYxatplixZwrRp05g7dy5RUVHMnj2bIUOGcPToUYKDg/9zfEFBAddddx3BwcEsW7aMBg0acPr0aQICAqwRvxCOS+pFhBCiSixORt5//30mTpzI+PHjAZg7dy4rV65k/vz5PPfcc/85fv78+aSlpbFlyxbc3NwAiIyMrFnUQjiDRFlJI4QQVWHRNE1BQQE7d+4kOjq65AR6PdHR0cTExJT7mF9++YXevXszZcoUQkJCaN++PW+88QYGg+GK18nPzycjI6PMhxBOxWiA5MPqbVttkCeEELWERclIamoqBoOBkJCQMveHhISQmJhY7mPi4uJYtmwZBoOB33//nZdeeon33nuP11577YrXmTVrFv7+/uaPiIgIS8IUQnsXYqEoF9y8IbCJ1tEIIYRDs/lqGqPRSHBwMJ9//jndunVj1KhRvPDCC8ydO/eKj5k+fTrp6enmj7Nnz9o6TCGsK3Gf+jmkHehdtI1FCCEcnEU1I0FBQbi4uJCUlFTm/qSkJEJDQ8t9TFhYGG5ubri4lPxBbtOmDYmJiRQUFODu/t/9Ojw8PPDw8LAkNCEcS1LxnjRSLyKEEJWyaGTE3d2dbt26sXbtWvN9RqORtWvX0rt373If07dvX06cOIHRaDTfd+zYMcLCwspNRISoFUwb5MlKGiGEqJTF0zTTpk1j3rx5LFq0iMOHDzN58mSys7PNq2vGjh3L9OnTzcdPnjyZtLQ0HnvsMY4dO8bKlSt54403mDJlivV+CiEcjXlZrxSvCiFEZSxe2jtq1ChSUlKYMWMGiYmJdO7cmVWrVpmLWs+cOYNeX5LjREREsHr1ap544gk6duxIgwYNeOyxx3j22Wet91MI4UiyUiArEdBBcFutoxFCCIenUxRF0TqIymRkZODv7096ejp+fn5ahyNExWL/hq9vg8Cm8OhuraMRQgjNVPX1W/amEcLazPUi0gZeCCGqQpIRIazNvJJGkhEhhKgKSUaEsDbZIE8IISwiyYgQ1lSYB6nH1NuyrFcIIapEkhEhrCnlCBiLwDMA/BpoHY0QQjgFSUaEsKakUsWrOp22sQghhJOQZEQIa5KVNEIIYTFJRoSwJileFUIIi0kyIoS1KAokFScjskEeRqPC5hOppGUXaB2K2Y5TaaRk5msdRq13NDGTo4mZWochnIjF7eCFEFeQfhby0kHvCvVbaR2NpooMRp79cT8/7jpH/5b1+er+nlqHxLaTaYz8LIZGgd788Vg/fDzkz58tHE3M5OaP/qHQoHBzxzCeHdqaiEBvrcMSDk5GRoSwFlO9SP3W4OqhbSwayis0MPnbXfy46xwAm46nOMRoxPLdajxn0nJ4848jGkdTOymKwoyfD1BoUHcZ+W1fAoPf28Cs3w+TnluocXTCkUkyIoS1mDuvXr1TNFn5RYxfsJ01h5Jwd9UT5u+JUYFVBxM1javIYGTVgZIYvv73NFtOpGoYUe30674Etp5Mw8NVzxdju9O3eT0KDEY+2xjHwHfWsWjLKQoNRq3DFA5IkhEhrCVxn/r5Km12djG7gHvm/UtM3AV83F1YOL4H9/dtAsDKffGaxhYTd4GLOYXU83FndM8IAJ5eto+s/CJN46pNsvKLeH3lIQCmDGpOdNsQvpkQxfz7utM8uA4XcwqZ+ctBhszeyJpDSTjBHq3CjiQZEcJaruJlvYnpeYz8LIa959Kp6+3G4om96NMsiBs6hAKw9WQayZl5msW3cl8CAEPbh/LiTW1pWNeL85dyeeP3w5rFVNt8tPY4SRn5NAr05sH+TQHQ6XRc2zqEVY/14/+Gt6eejztxKdlM/GoHd8/byoHz6RpHLRyFJCNCWEN+Jlw8qd6+yjbIO5WazYi5WzienEWonyc/PNSbThEBADSs603niAAUhTLTJPZUaDCap4lu6hiGj4crb4/oCMDirWf453iKJnHVJieSM/lyk/r7P3NYWzzdXMp839VFz5hejVn39EAmD2yGu6uemLgLDJuziSd/2EtCeq4WYYtSjEZtR6okGRE2dyQxg+k/7eeT9SdYfzRZ03fINpN0UP3sGw4+9ar8sIIiI38eTGTmzweccink4YQMRsyN4dzFXBrX82bppN60CPEtc8zNHcMAtZhRC1tiL3App5CgOu5ENVH/bfo0C2Jc78YAPLtsH5l5UlxZXYqi8PIvhygyKgxuHczgNiFXPNbP041nh7bm7ycHcGvncBQFftx1jkHvrue9P4+SbcVps/wiAwfOp/PDjrO8+ushPl53ggz5dy7Xt1tPM27BNvIKDZrFIGvbhE2dvpDNvV9sJTWrbK+JoDoetAnzpW24H23D1I8mQT64ujhpfmxudlZ5vYiiKByMz2DZznP8sjfe3IfjnxOprHqsP+6uzvEc7DydxvgF28nIK6J1qC9fTehJsK/nf467oUMYr608zPZTaSRl5BHi999jbMlUr3JD+zBc9CUt+p+9oTXrjqZwJi2H11ce5s07Oto1rtrijwOJbDqRirurnhnD2lbpMQ3revO/u7owvm8TXvvtEDtOX+Sjv0/w3bazPHV9S+7sHlHm36oyadkFHE7I4FB8hvo5IYMTyVkUXfZu/8tNJ3k8ugWjezbCzVn/1ljZJ+tP8PaqowCs2H2eu3o20iQOSUaEzaRm5TN2/jZSswpoFeJLq1BfDiVkEJeSRWpWPv8cz+ef4yUrGjxc9bQO9aVNmJ85SWkd5kcdZ+gHkVh5s7OUzHx+3nOeZTvPcaTUKEh9Xw8KDUbiUrL5ctNJJg9sZutoa2zjsRQe+nonuYUGujWuy/xxPfD3div32AYBXnRtFMCuM5f4Y38C9xUXtdpDQZGR1QeTALixQ1iZ73m7u/LunZ0Y9XkM328/y9D2oQxsFWy32GqDnIIiXvtNLVqd1L8pjev5WPT4zhEBLJ3Um1UHEnlz1RFOX8jhuZ/2s3DLKZ6/sQ39W9Yvc7zRqHA6LadM0nEoPoPEjPJHW/293Ggb5kerUF/+OZ5CbEo2M34+qJ7/hjYMbhOM7irdQ0pRFN5cdYTPNsQBMHVQc0b1iNAsHif4Ky+cUXZ+Efcv3M7pCzk0rOvF1w+UvGvOLTBwNCnT/E7mUIL6hyWnwMDec+nsPVe2qK1xPW/ahvnRJsyPDg386dciyPFGUJLKL17NLzKw9nAyy3aeY8OxFAzF79TcXfVc1zaEEV0b0q9FED/viefJpXv56O/jDO8STpi/l71/gir7fX8Cj32/m0KDQv+W9Zl7b1e83Sv+U3JTx3B2nbnESjsnI5tjU0nPLSSojgc9mwT+5/s9mwRyX59IFmw+xXM/7mf1E/3x9yo/qRL/9fG6E8Sn59EgwIvJA5tX6xw6nY4bOoQxuE0IX8Wc4qO/T3AkMZOx87cxoGV9otuGcDRR/VtxJDGTnILypxJK/51oW/yGJszf05xsFBqMfL/tDB/8dZy4lGwe+GoHvZvW44Wb2tC+gX+1nwNnZDAqvLhiP99tOwvACze2YWJx0bFWdIoTrK/KyMjA39+f9PR0/Pz8tA5HVKLQYOSBRTvYcCyFut5u/Di5D03r16nwMUajwpm0HPM7HVOCkpD+33c8j0e34PHolrYK33JGA7zRAIpyYeoOlHrN2XsunWU7z/Lr3oQyzZ66NArgjq4NGdYxvMxIgqIo3Dk3hh2nL3JzxzDm3N1Vi5+kUku2n2H6T/sxKnBThzA+GNW5StNKCem59J71NwD/Th9MqL99pmqeXrqXpTvPMbZ3Y169tfxRq9wCAzf8byOnLuQwoltD3r2zk11ic3YnU7MZ8sFGtY/ImG4MaRdqlfNeyingw7Un+PrfU+bmaaWVN4LaKtQXX8+qJZEZeYV8si6W+ZtPUlBkRKeD27s05Okhrez2e6mlgiIjTyzZw8r9Ceh1MOv2DozqYbupmaq+fksyIqxKURSeWrqPH3edw9NNz3cTe9GlUd1qn6/0XPD2U2n8eSiJut5ubHluMF7uLpWfwB5SjsHHPTC6evFZ3w0s25VAbEq2+dth/p7c1qUBd3RrSLMKkrKD8ekM+2gTRgUWT4yiT7Mge0RfZZ9vjOWN39XOpaN7RvDa8A4WzeuP+HQLO05fZMbNbbn/GtuPjhQUGen+2hoy8opY8mAvoppeubB4x6k07vwsBkWB+fd159rWVy7CFOr/8/sWbGfDsRT6t6zPovE9rD7dcSo1m4/+PkFKVr5aX2bl2rKzaTm8s/oov+xVa4o83fQ82K8pDw1oVmu3CsgpKGLSN7vYeCwFNxcd/7ury3+mL61NkhGhiXdWH+HjdbG46HV8PqZbhZX1ljIYFQa+u46zabm8Nrw99/ZqbLVzV1deoYH9qxfQY8eT7DY257aCVwH1D9vQdqHc0a0hfZoFVflFe8bPB/gq5jQtguvw+2P9HKLITlEU3ll9lE/WxwLw0ICmPDe0tcUvPgs2n+SVXw/RvXFdlk3uY4tQy1h3JJnxC7cT7OtBzPTBlf4bvL7yEPP+OUmwrwdrnhhwxRoYAX8eTOTBr3fi5qJj9eP9Kx35dGS7z1zk9ZWH2XH6IqDWcD11fUtGdLOsiNbRpecUcv+i7ew8fREvNxc+H9uNfi3qV/7AGqrq67f2f+lErfF1zCk+Xqe+YL0+vL1VExEAF72O8X3Ud9TzN52s/rr4U5vgo26wZoa6sV017Tx9kag31rL93w0AHDY2omdkIG/d0YHtL0Qz+64u9GtR36I/aE9e14pAH3eOJ2exaMupasdmLUajwosrDpgTkWeGtmL6DW2q9S74hvZh6HSw4/RFu/SVMC0lvrFDWJX+DZ68vhVN6/uQnJnPK78etHV4VpOWXcDoz/9l2pI95F6hnsKa8goNvFpctDqxX1OnTkQAujSqy9JJvfn0nq40rudNSmY+z/64n5s+/KfW9KBJzsxj1Ocx7Dx9ET9PV755IMouiYglJBkRVrHqQAIzflH/gD8R3dJmy8NG9ojA19OVuNRs/j6SXL2T7P4GLpyAzf+DD7vAtnlgsKz/gKIovPLrQdJzC+nirm7ANiT6On6Y1JtRPRpVef76cv7ebjw7VN3xd/Zfx0m+wioBeyg0GHl8yR6+3XoGnQ5ev609D1ezSBEg1N+T7o3VKbvf99u2AVp+kYE/D5U0OqsKTzcX3r2zE3od/LT7PH9qvJ9OVRiNCo8v2UNM3AV+2n2eMV9utfmGdJ+sj+XcxVzC/D2Zem31fx8ciamI9s8n+vPiTW3w83TlSGImY77cxn0LtnEsyfl6AJmcTcth5NwYjiRmUt/Xgx8m9aZb4+pPnduKJCOixradTOPR7/egKDC6ZyMeHWy7P1B1PFy5uzjRmfdPXPVOcm6H+tkrEHIuwO9Pwad94OgqqOKs5YZjKew7l46nm56e3uqcc72m3aoXz2Xu7BZB54gAsvKLNGtXnltg4MGvdvDL3nhc9To+vKsL90TVfFrspuL5aVvvVbPpeCqZeUWE+HnQzYKapa6N6ppXFTy//AAXswsqeYS25qw7wcZjKXi66fHzdGXH6Yvc9fm/Ntsl+cyFHOZuUEfJXrypbaWrqJyNh6sLD/RryoanBzG+bySueh3rj6YwdPZGnl++3yF2n7bE8aRMRszdwqniVY1LH+pN61DHLHWQZMSBbI27wOcbY8kpcJ7Nu44lZfLAou0UFBmJbhPC/93azubr9u8r/iOx9WQa+89ZOM2SexEuHFdvPxwDN74L3vUg9Rh8Nwq+ugUS9lV4CkVR+OjvEwBM7OqHS1bxO+iQqjV8qoxer+PVW9uh08GKPfFsjbtglfNWVVZ+EePmb2PdUfVFbt647gzrFG6Vc9/QQZ2q2XXmEucv2W6qZmWpKRq9hfP+T0S3pHlwHVKz8nnZgadr/jmewgd/HQPgteEdWPJQb4LqeHA4IYM7527h3MUcq1/z1d8OUlBkpG/zetzYwTqrZxxRXR93Zg5rx5ppAxjSLkQtKt96hkHvrueDNceIt+HvrrXsPXuJkZ/FkJSRT4vgOiyb1IfIIMv6wNiTJCMOIC4li4lf7WDU5//yxu9HeOWXQ1qHVCUJ6bmMm7+NjLwiujYK4KPRXezS/yPM38s89P7FJgtHR87vUj/XjQTfUOg5ER7dDX0fAxd3OLkRPusPKx6GjPLfvcfEXmDn6Yu4u+q5v3mWemdgU/DwLff46ujYMIDRxSNAM385SJGdtl0vKDLy0Nc72HYqDV9PV76eEMUgKzYCC/HzpEek2u/jj/22aQ+fX2RgzSG10dlN1Vgp4Onmwnt3dsJFr+PnPfGsOqBNG/uKJKTn8ph5NDKCEd0a0ibMj2WTetMgwEtdpvxpDCeSrTe98PeRJP46nIyrXscrt9j+TYcjaBLkw2djurPkwV50bOhPVn4R/1t7nL5v/c29X2xlxe7zdqnTsdSW2FTunvcvF3MK6RQRwA8P9Xb4ZcuSjGgoLbuAl385yPUfqFtqu+h16HSwZMdZ1h2tZj2EnaTnFnLf/O0kpOfRrL4PX47rYdeltg9cow6lr9yXYNm7lPM71c8Nupfc5+kP170KU3dA+zsABfZ8qxa5rnsD8rPKnOLDv9WRlbt6RFA3Q22jXFHn1ep6+vpWBHi7cSQxk6//PW3181/OaFR4etleNp+4gLe7C99MiDInDtZk671q/jmWSmZ+EaF+nnSt5rLyThEBTBqg/o69sPwAF7IcZ3i+0GBkyre7SMsuoF24HzOHtTN/LzLIhx8n96F5cB0SM/K4c24M+85dqvE18woNvPKr+ibp/mua0DzYeom3M4hqWo8VD/flw9FdiGoSiKLAphOpPL5kDz1e/4tnlu1l28k0HGFx6p8HE7lvwXayCwz0aVaPbx+Ioq6Pu9ZhVUqSEQ3kFxn4bEMsA95Zx8Itp8wbTK1+vJ95tchzP+6zeSFadeUVGpj41Q6OJmUS7OvBovt72v2XvUNDf6KaBFJkVCxbdWKqF2nY/b/fq9sYRsyHB9ZCRBQU5sCGt9SkZNdXYDSw/VQa/8al4eaiY9KAZqU6r1p/X5O6Pu48PUQtZn3/z2M2n69+c9URft6j1oh8em8388671ja0fSg6Hew5e4mzadafSli5v/pTNKU9OrgFrUJ8uZBdYC7OdgSzfj/CrjOX8PV05ZN7uv5nh9xQ/+Kdkxv6czGnkLvnbSUmtmZTffM2xnH6Qg4hfh48OrhFjc7lrPR6Hbd0CmfJQ73555lBPB7dgohAL7Lyi/hhxzlGfhbDgHfW87+/jtvk97oqftx5jsnf7qKgyMj1bUOYf18P59hOA0lG7EpRFH7bF8/g9zYw648jZOYV0TbMj8UPRPHlfT1oHuzL00Na0STIh6SMfP7vN8ebrjEYFab9sIdtJ9Oo4+HKwvE9aVjXW5NYJvZT37ku3naGrKrs9qkocL44GWlQTjJi0rA73L8a7lykTudkJcIvj8Bn/fl75RIARnRrSHiAFySakhHrj4wA3NWjER0a+JOZX8Rbq47Y5BqgbiD2+UZ1yuutOzoyoKXtlv0F+3oSVdya/Q8rT4HkFZaaoqniKpor8XBVV9e46HWs3JdgrkPR0u/7E5i/+SQA793Z6Yp7wQT6uPPtxF70blpPrQFasI2/ip8XS527mMPH69UaqedvbOM0L262FBHozePRLdnw1CCWPNiLkd0b4uPuwpm0HD746xj93l7HqM9iWLrjbNX+NlnBgs0neXLpXgxGhTu6Niw3UXVkkozYyc7TF7nj0y1MXbybcxdzCfHz4J0RHfn1kWvo07yk06aXuwvv3tkRnQ6W7TzH2sPV+wNiC4qi8H+/HeL3/Ym4uahNzdqGa1eZfW3rYJoG+ZCZV8QP289W/oCLp9TVM3q3/+wh8x86HbQbDlO2wfWvq1M5SQd4NuU5Fri/zSMdDFCUD6m2m6YBtbfKK7eqw/DLdp5jZ3FjJmv6dW+8OfF9Zmgr7ujW0OrXuNxNHdWC2JVWXuK78VgKWflFhPt70sUKIzsdGvozpXjjwpd+PkCqhtM1cSlZPLNMLa5+aEBTrq+k/XodD1cWjO/BdW1D1Fqgb3ayfPc5i6/72m+HySs0EtUkkFusVMhcW+j1OqKa1uPtEZ3Y/mI0H4zqxDXNg9DpYOvJNJ5eto8er/3FtCV72HIitfq9kSqgKAqz/zpmnkYb3zeSd0Z0dLz9uyrhXNE6obNpOUxZvIs7Pt3CrjOX8HJz4Ynolqx7auAVt8nu1jiQB4rbZU//aT+XchxjeeHcDXEsLJ4SeW9k5zJJlBb0ep25rfj8zSfNm9BdkaleJLQDuFWxmMvVA/pMhUf3sMbvdgoVFwbp9xC+eDAsHQ/GIvAMAH/bvYB3bVSXkd3V88/4+UDlP6cFtsSm8uQPewEY17sxkwfYZ8fgoe1C0evUin9rDmlba4qmtKnXtqB1qC9p2QW8tOKAJnUBuQUGHv52F1n5RfRsEsjT17eq0uM83Vz49J6u3N61AQajwhNL9lo0rbnxWAqrDibiotfx6q3tr4qi1erydnflti4N+eaBKDY/ey1PD2lF0yAfcgsN/LT7PHd/sZV+b6/j3dVHOZmaXfkJq8BoVHjl10PM/kutY5t2XUtm3NzWar/79iTjbTaSnlvIx+tOsHDzKQoM6mZMI7tF8OT1LQn2q/yF8MnrW7H2SDJxKdm88ushPhjV2fZBV+CnXefM0wQv3tTGYd4h3dG1Ie/9eZRzF3NZfTCx4n0WKqoXqcTBSy5MTB5BE31ffm39F3Xi/oCjK9VvhnZQR1Js6NmhrVl1IJGD8Rks3naGMVZohX8oPoOHvtpJgcHIjR1CmTHMfisk6vt60KtpPbbEXuD3/Qk8ZIUkKK/QYJ6KqOkUTWnurnreG9mJW+ds5o8Dify6L8Guv/+KovDCiv0cScwkqI4HcyxctebqoufdEZ3w83Rj4ZZTzPxFbdb3yLXNK/z3zi8y8HJxrcy43pG0Cr26ilZrIjzAiymDmvPwwGbsOnOJH3ed49e98Zy/lMucdSeYs+4EjQK9cXWp2f+3/EKjeYn8y8Pa2nVHbGuTkRErKzQYWbj5JAPfWcfnG+MoMBi5pnkQKx/px1sjOlYpEYGy3SCXa9wNcsOxFPPw8APXNOGBftpuNV2al7uLeY+aLyprglaVepErmFPcV6R9h27UGfs93Pc7hHVWvxl5jcXns1S9Oh48Wfxu+N3VR0mrYTOucxdzuG/BNjKL32m/P7Kz3ffhMCWOK620xHf90RSyCww0CPCis5WLb9uF+/PItWrh5oyfD5Ccab/OuEu2n+WnXefR6+Cj0V2q/DekNL1ex8xhbXk8Wv0Z3l9zjNdWHq5w2mD+plPEpWYTVMeDx6+7OotWa0qn09GtcV3euE3dIuKj0V0Y2Ko+eh2cScshLiW7Rh/nL+Xiotfx/shOTp2IgIyMWI2iKKw5lMSbfxwhrngIrnlwHV64sQ0DW9Wv1jvOro3q8mD/ZszdEMvzyw/QIzLQ7qtW9p9LZ/I3OykyKtzSKZznb2xj1+tXxZjejflsQxy7zlxi5+mL5bc6LiooaWZm4cjIsaRM/jigJoNTBxV3l43sCxPXqc3SglrWJPwquyeqEd9vP8vhhAzeXnWEN++o3gqei9kFjJu/jeTMfFqF+DJvbHdNCt2Gtg9lxs8H2HcunTMXcmhUr2aF0Kak5qaOYTYZ4Xl4UDP+PKSOTr2w/ACfj+lm85GkA+fTzSt5nhrSit7NrrzzcGV0Oh2PR7fEz9ONV387xJebTpKeW8ibt3f4z0hLQnouHxUvYZ9+Q2v8qrm9gSjh6ebCsE7hDOsUTnJmHqdSrTM9GRHoRZi/l1XOpSVJRqwgNiWLF5bv59+4NADq+bjzxHUtuatHRI2LiB6PbsHaw0kcT85i5i8H+XB0F2uEXCWnL2QzfuE2corXq79zZ0eHnIsM9vXk1s7hLN15ji83xdGtcTlt2ZP2gyEfvOqqDcosYBoVGdoutOxQtV4Pwa1rErpFXF30/N+t7RgxN4YlO85yV89GFo8A5BUaeOCrHcSmZBPm78nC+3vg76XNC01QHQ96N6vH5hMXWLk/gckDqz9Vk1doMBd722pLdDcXPe/e2Ylb5mxizaEkfthxllE9bLMHE6i7rE7+dicFRUYGtw5mUn/r1PPcf00T/L3ceObHfSzbeY7MvEL+d1eXMgnpaysPk1NgoHvjutzetYFVritKBPt6Euzr2E3I7E2maazg1V8P8W9cGh6ueh4e2Iz1Tw/k3l6NrVLNbJqucdHr+GVvvM26Vl7uWFImIz+LITWrgDZhfnw2phsero67TGxCP3WIctWBxPILIs+Zmp11s6i+Iy4li9+K91FxhE3BukcGcnvXBigKzPz5gEXV+UUGI498t9u8c+ei+3tq/o7qpg6mVTU126tm/dFkcoqnaDo19LdGaOVqE+bHY8V9Np79cT8PLNrOieSsSh5lOUVReHLpXs6m5dKwrhfvj+xs1TcCd3RryKf3dMXdRc/qg0lMWLTdvAR1y4lUVu5LQK+DV+ywvYMQIMmIVZy+oE7LfDamG88MbV3tHVuvpHQ3yBdX2L4b5OV7Giwa38PqP5O1tQ71o1+LIIwK5j4MZVSzXuTjdbEYFRjcOpj2DWz3ImeJ525oja+HK3vPpbNkRxWWNKO+uM345SBrDiXh7qrny/t60DJE+4LEIe1CcNHrOHA+g1M1WGFg6uZ6s42maEqbNKAZ4/tG4qLX8dfhZIbM3siMn637//KzjXH8dTgJdxc9n97TDX9v6///u75dKAvH98DH3YXNJy5wzxdbScnMZ2bxtNC9vRrTLtwxfudF7SfJSA0pikJCulrM1qx+HZtdx17dIE17GlwqtadBdQrmtGAqrP1h+9n/dq+txkqaMxdyWLHnPACPOFDXyWBfTx6/Tq1TeXvVkSot/f7o7xMs3noGnQ4+vKuzTdq8V0e9Oh70Ka6DqG4ha26BgbWH1e0TrLmK5kpcXfTMHNaO1Y/3J7pNMAajwlcxpxn4znrmboglr7Bme5X8G3eBd1ar/Wtm3tKWDjYc6enTPIhvJ/YiwNuNvWcvce176zmenEU9H3eevK5qy4eFsAZJRmooLbuA/CJ16W6IDV+0PVxdeG9kSTfI32ywBbuz7mlg0r9FEK1CfMkuMPD9tjMl38hJgzR123MalFNPcgWfbjiBwajQr0WQ1Vdn1NS43o1pFeLLxZxC3v3zaIXHLtl+hvfXqLu7vnpLO4a2t/0LtiVMm9lVt8PpuqPJ5BYaiAj0ooMdR6+aB9fhi3E9WPxAFG3D/MjML+LNP44Q/f4GftkbX61+JMmZeTzy3W4MRoXbujTg7p62q0kx6RwRwNKHehPi50FmnjpV8+zQ1jYZjRHiSiQZqaH4S+qoSFAdD9xdbft0tm9QqhvkigNW3avEmfc0MNHpdObakYVbTlFo2unWtFNvYFPwrtqIwPlLuSzbqXardMS9OFxd9ObOrN9uPcOB8+nlHvf3kSSeX662rJ8yqBljekfaK8QqG9IuFBe9jkMJGdVqBmVKYm7qEK5JfUOf5kH8+sg1vDOiIyF+Hpy7mMuj3+3m9k+3sPN0WpXPU2Qw8uh3u0nJzKdlSB1ev81+TcZahPiybFIfekYGcmvncEbYoQuvEKVJMlJD8elqw5nwAPsUApq6QV7MKeTFFfut0g1y/qaSPQ1GdHO+PQ1Ku7VzOEF1PEhIz+N307B/NepFPtsQS6FBoVfTQIeZ0rhcr6b1uKVTOIqitiq/vJh195mLPPztLvO/61NV7Nppb3V93Olb3M33dwunanIKilh7RF1Fc7MdpmiuxEWv487uEax7aiBPRLfE292F3WcuccenMUz5dhdnLlS+jPO9Ncf4Ny4NH3cXPr23G97u9n0zEBHozQ+TevO/u7o45Ko5UbtJMlJDCcXd78L97VNXYeoG6arXsfpgEr/srf50jaIofLDmGK8W70tyf98mvH2H8+1pUJqHqwvjeqtN0Ob9E6cmaxbWiyRn5PF98V43j17reKMipb1wUxt8il/4ftxVsu9IXEoW9y/cTl6hkYGt6jPr9g4OvSri5uKpmt8snKr5+0gyeYVGGtfzpp2G+ySZeLu78lh0C9Y/NZBR3SPQ6dRamOj3N/D6ykOk55S/E/dfh5L4dL06lfjWiI42rT8TwhE576uOgzAVr9pziWTpbpAzfzlYrW6Qpj0N/rdWbWz05HUteenmNrXiHdE9vRrj6abnwPkMtsZdKNmTpoojI59vjKOgyEi3xnVr1GTKHkL8PM3TSG/+cYT03EKSM/MYO38bF3MK6djQn4/v7oqbgyeY17cLwVWv43BCBrEpVV8qaxpJuamD7VfRWCLYz5O3RnTk90f70a9FEAUGI/P+OcmAd9exYPPJkilE1P2rpv2wB4D7+kRyc0fH2GpBCHty7L9QTsC0L0B4gH1XnDw8qBntwv24lFPIC8st27yryGDkqWV7zZvevXJLOx4Z3MKh/pjXRKCPO3d0Vee8f1m3CXLTwMUdQivfWfdCVj7fblWLXyvbu8NRjO/bhGb1fbiQXcDrKw8xfsF2zl3MJbKeN/Pv64GPE9T+BHiXmqqp4uhIdn4Rfx9RV9HYqtFZTbUJ8+Or+3uyYHwPWgTX4VJOIa/8eojrP9jI6oOJ5BUamPztTjLyiugcEeCQHY6FsAdJRmrINDJir5oRE1M3SDcXHWsOJZmXoFYmr9DApG928dOu87jodXwwqhPj+kTaNlgNmHbzzYrbpt4R2lHdgbcSX2w6SW6hgY4N/RnQsr4tQ7Qad1c9r96qJlo/7DjHwfgMguq4s+j+ngTVqfxndhSmZblVXeJrmqKJdJApmivR6XQMahXMH4/14/Xb2hNUx52Tqdk89PVOBryzjgPnM6jr7cbH93S1eRG8EI5KfvNryFQzEmanmpHSSneDnPnzQZIyKp6uycovYvyC7WozJVc9n93bjdu61M6q+Wb16xDdJpguerWVe1XqRS7lFPBV8WjR1EHOMSpi0rd5kHmJrLe7C/Pv60Hjej4aR2WZIW1DcXPRcSQxkxPJmZUeb15FY4dGZ9bg6qLnnqjGrHtqIFMGNcPDVU9SRj46Hcy+qwsN7PyGRghHIslIDRiMCknFy2vtPTJiMmlAMzo08Ccjr4jnf7ry6pq07ALunvcvMXEXqOPhyqLxPYluG2LnaO1rwjVN6axXiwKzgjpXevz8zafILjDQOtSX65zwuXnl1naM7tmIRff3pGPDAK3DsZi/txvXFE/VrNxX8S7VWflFrDta3Oisg3PVWPh6uvH0kNb8/dRAJvZrwgcjOzvNKJwQtiLJSA0kZ+ZhMCq46nWaDYe7uqira9xd9Kw9ksyPu/47XZOQnsvIz2LYdy6dut5uLJ4Y5fCFmdbQq5EP7fWnAPgxKbTCYzPyCllQ3Eb+kWuds34mqI4Hs27v4LBLkavipo5V26tm7eEk8ouMNA3yoU2Y9m3tq6NBgBcv3NSW4V1kIzohJBmpgfjiKZpQf09cNFyF0jLEl8evU6drXvn1IAnFvU8ATqVmM+LTGE4kZxHm78nSSb2d8l1zdeiSDuJGERcUXz7aXUR+0ZXbdH+15RSZeUU0D67DDe0rTlyE7VzXNgQ3Fx3HkrI4nnTlqRpnm6IRQlRMkpEaMHVfDdd451OAB/s1pVNEAJl5RTz3ozpdcyg+gxFzYzh/KZcmQT4sndSb5sHO+S6yWoqbnR3RtyA1u4Cf95T/bjs7v4gvN6mjIlMHNa8Vy5udlb+XG/1bqFMWVypkzcwrZP2xFMA+e9EIIWxPkpEaMI1AhNl5WW95XF30vHdnR9xd9Ww4lsKrvx3irs9jSM3Kp02YHz881JuGdb21DtO+ipuduTfuAcCX/5wst6bmm39PczGnkMh63pp28RQq86qaKyzxXXs4mYIiI83q+9DKAXYeFkLUnCQjNWAaGbFnw7OKNA/25cni3VwXbD5FRl4R3RvX5fsHe1Hf13mWeFpN8chI2x7X4u3uwtGkTP45nlrmkNwCA/P+iQPg4UHNnbr7bG0R3TYEdxc9x5OzOFbOVI1pxOSmjtrsRSOEsD75y1sD8Ro1PKvIA/2a0rVRAAADWtbn6wlR+Htdhbtv5qRBmppk+DTpycjuEYDaR6S077adITWrgIZ1vbhNCgkdgp+nG/1bqqtqLm8Pn5lXyIajxVM0DtroTAhhOUlGasDc8MxBRkZA3bDr6wlRLBzfgy/GdcfL3Tk3vKsxUwv4wGbgHcj9fZug18HGYykcTVTfbecVGvhso7r0d/LAZg7fMv1qUjJVE19mau2vw0kUGIw0D65DyxDZv0WI2kL++taAI9WMlObj4crAVsFX94vrZZvjNarnzZB26iqZLzepIybLdp4jKSOfMH9P2TLdwUS3CcHdVU9sSjZHS03VmFfRONheNEKImqnWq9XHH39MZGQknp6eREVFsW3btio97vvvv0en0zF8+PDqXNah5BUaSM0qABxrZEQUK64XKb053gP9mgKwYnc88ZdyzbukPtS/KR6uV+kIkoPy9XQzNwIzJSDpuYVsPKbW/MgqGiFqF4uTkSVLljBt2jRmzpzJrl276NSpE0OGDCE5ObnCx506dYqnnnqKfv36VTtYR5JYPEXj6aYnwPsqrMlwZIpSMk3TsJv57m6N69KlUQAFBiPjF2zn/KVcgup4cFfPRhoFKipyc6lVNYqi8NchdYqmZUgdWsoqGiFqFYuTkffff5+JEycyfvx42rZty9y5c/H29mb+/PlXfIzBYOCee+7hlVdeoWnTpjUK2FHEp5uKV71kuNjRpMVB7kVw8YCQDmW+NbF4dMQ09P9Q/6Z4usmoiCMaXDxVE5eazeGEzJJVNE7W/l0IUTmLkpGCggJ27txJdHR0yQn0eqKjo4mJibni41599VWCg4OZMGFCla6Tn59PRkZGmQ9Hk+BADc/EZUz1ImEdwdW9zLeubxtCw7rqv1mgjzv39JJREUdVx8OVQa3UqZrvtp3hn+OmRmfSIVeI2saiZCQ1NRWDwUBISNlNxEJCQkhMLH9jq02bNvHll18yb968Kl9n1qxZ+Pv7mz8iIiIsCdMuzMWrGuzWKypRTr2IiauLniei1V4sT1zXEm93V3tGJixk2qvmm62nKTQotA71vbq6CAtxlbDpcovMzEzGjBnDvHnzCAoKqvLjpk+fTnp6uvnj7NmzNoyyes6bGp7Jtt+O57KVNJe7o1tDDr86lDG9GtsxKFEdg1sH4+Gqx7S6V3qLCFE7WfS2MCgoCBcXF5KSksrcn5SURGjof4dOY2NjOXXqFMOGDTPfZzQa1Qu7unL06FGaNWv2n8d5eHjg4eHYHUNNIyMNHGxZ71WvMA8S96u3G3S74mFXbf8VJ+Pj4cqgVsGsOqiOvN4oq2iEqJUsGhlxd3enW7durF271nyf0Whk7dq19O7d+z/Ht27dmv3797Nnzx7zxy233MKgQYPYs2ePQ06/VFWCg7WCF8US94OxELzrQd1IraMRVnBrZ3Wqpl24H83qS6MzIWojiyfMp02bxrhx4+jevTs9e/Zk9uzZZGdnM378eADGjh1LgwYNmDVrFp6enrRv377M4wMCAgD+c7+zKVlNIyMjDqV0vYiscqoVhrYPZc7dXejYIEDrUIQQNmJxMjJq1ChSUlKYMWMGiYmJdO7cmVWrVpmLWs+cOYNeX7s7f2bmFZKZVwTIyIjDqaReRDgfnU7HzR1lOa8QtVm1lhJMnTqVqVOnlvu99evXV/jYhQsXVueSDsW0J42/lxs+HrIaw6GYR0auXC8ihBDCsdTuIQwbMe3WK8t6HUx2Klw8pd6WZEQIIZyGJCPVYN6tV5b1OhZTC/h6LcArQNNQhBBCVJ0kI9UgIyMOSupFhBDCKUkyUg3xl2RkxCFJvYgQQjglSUaqIUGW9Toeo7HUTr0yMiKEEM5EkpFqMNWMyLJeB5IWC3np4OoJIc7dw0YIIa42koxYSFEUc82I7NjrQMw79XYCFzdtYxFCCGERSUYslJZdQH6Rur9OiL9j759zValgp14hhBCOTZIRC5mmaOr7euDhKputOQzzShopXhVCCGcjyYiFSqZopHjVYRTmQtIB9baMjAghhNORZMRCUrzqgBL2gbEIfOpDQCOtoxFCCGEhSUYsZG54Jst6HYfs1CuEEE5NkhELxRePjDSQhmeOQ+pFhBDCqUkyYqEEcyt4SUYchqykEUIIpybJiIXMNSMyTeMYslLg0hlABw26ah2NEEKIapBkxAIGo0JiRvG+NDIy4hhMoyJBLcHTX9tYhBBCVIskIxZIzszDYFRw1euo7ysNzxyC7EcjhBBOT5IRC5h26w3x88RFL6s2HMI52alXCCGcnSQjFpDdeh2M0Qjnd6m3ZWRECCGcliQjFoiXlTSO5cIJyE8HVy8Ibqd1NEIIIapJkhELmKZpwqXHiGMwFa+GdwYXV01DEUIIUX2SjFhApmkcjNSLCCFErSDJiAVkXxoHYxoZkXoRIYRwapKMWKCkZkRGRjRXmAtJB9XbMjIihBBOTZKRKsovMpCaVQDIvjQOIWFv8U69weAfoXU0QgghakCSkSpKLJ6i8XTTE+DtpnE0omRzPNmpVwghnJ0kI1VkXknj74VOXvy0d16KV4UQoraQZKSKzPUispLGMZyTNvBCCFFbSDJSRaZlvbKSxgFkJUN68U694bJTrxBCODvpFFVF8enS8KxKFAUyEyHpACTug8T96kd2CnS8CwY8Cz71anYNU71I/Vbg6VfzmIUQQmhKkpEqSiiepgmXZb0lDEVw4XhxwrEPEg+ot3NSyz9+22ew93vo/xREPQSu1dz52FwvIlM0QghRG0gyUkWmAtawq3VkJC9d7ethGulI3A/Jh8GQ/99jdS4Q1BJC20NoB/XDaIC1r6iPW/MSbP8Col+GdrdZvhrGvJJGileFEKI2kGSkiuLTr7KREUWBE3/BrkWQsA8unS7/OHffkqQjpPhzcBtwKydpa3Yt7P0O1v6fer5l4+HfT2HI6xDRs2pxGY0Qv1u9LSMjQghRK0gyUgVZ+UVk5hUBV8nISOJ++PNFiFtf9n7/iJKEI7SDmoQERIK+inXQehfocq86GrLlI9j8Pzi3Db68Tr0v+mWoG1nxOVKPQX4GuHlDcFvLfzYhhBAOR5KRKjDVi/h5ulLHoxY/ZRkJsO412P0toICLO/SYCK2GqkmId6B1ruPuAwOfg67jSq53cDkcWQlRk6Dfk+AVUP5jTfUiYZ1lp14hhKgl5K95FZw3Fa/W1lGRguySkYrCHPW+qo5U1IRfGNz6sZqAmEZitnwIu7+BgdOh+3hwuazbrdSLCCFErSN9RqqgZLfeWlYvYjSoL/wfdYP1s9REpGEPmLAG7lxo20SktNAOMGYF3L0UglpBbhr88TR80huO/K7Wr5jIShohhKh1ZGSkChJq48hI3Ab48wW1PgQgoHH1V7dYg04HLa9Xi1x3LYJ1b6jLhr8fDZH94PrX1BU6SYfU46XzqhBC1BqSjFRBrWp4lnJMXVp7bJX6tYd/zft+WJOLK/SYAB3uhE3vQ8wncOof+HwgNOkHigHqhIJfA60jFUIIYSWSjFRBSSt4J56myU5Vp2J2LFBf0PWu0H2CdTqi2oKnnzpS0/1+WPsq7F8KJzeq35OdeoUQolaRZKQKzA3PnHFfmsI82Pop/PO+uiQWoNVNcN2rENRc29iqIqAR3PEFRE1Wp5XOxEDLoVpHJYQQwookGamEoijmHXsbONM0jaLAgR/hr1eKN5UDwjrB9a+r0x3OpmE3GP8H5FwAnyCtoxFCCGFFkoxU4mJOIflFRgBC/B2gpqIqkg7BL1Ph/E71a78GMHgGdBhZ9QZljkink0RECCFqIUlGKmEaFQmq44GHq4vG0VRBWhx8dYu6S657Hbjmceg1Bdy9tY5MCCGEKJckI5WINy/rdYLi1awU+OYONREJ7Qj3LAPfEK2jEkIIISrkxGP29mFqeBbu6MWr+VmweKQ6MhLQWBIRIYQQTkOSkUqYdusNc+SREUMhLL0P4neBdz249ydJRIQQQjgNSUYqkXDJwUdGFAV+fQxOrFF3sr37B+dYsiuEEEIUk2SkEqaaEYcdGfn7NdjzLehc1P1kpE26EEIIJyPJSCVKNslzwJGR7V/AP++qt4f9D1oO0TYeIYQQohokGamAwaiQmKEmIw7X8Ozwr7DyKfX2oBeg6xht4xFCCCGqSZKRCqRk5mMwKrjqddT3daCGZ6djYNkEQIFu46H/01pHJIQQQlSbJCMVOF9cLxLi54mL3kE2Zks+At+NAkO+usfMTe/JpnFCCCGcmiQjFXC43XrTz6tNzfLSISIKRnwJeifoCiuEEEJUQJKRCpiX9TpCvUjuJfh2BGScg6CWMPp7cHOAuIQQQogakmSkAg7T8KwwD76/B5IPgW8Y3PsjeAdqG5MQQghhJdVKRj7++GMiIyPx9PQkKiqKbdu2XfHYefPm0a9fP+rWrUvdunWJjo6u8HhHYt6XRstlvUYjLH8ITm8CDz+1zXtAI+3iEUIIIazM4mRkyZIlTJs2jZkzZ7Jr1y46derEkCFDSE5OLvf49evXM3r0aNatW0dMTAwRERFcf/31nD9/vsbB21pJjxGNRkYUBVZPh0MrwMUd7voWQttrE4sQQghhIzpFURRLHhAVFUWPHj2YM2cOAEajkYiICB555BGee+65Sh9vMBioW7cuc+bMYezYsVW6ZkZGBv7+/qSnp+Pn52dJuDXS/bW/SM3K57dHrqF9A3+7Xdds02z4a6Z6e8R8aH+H/WMQQgghqqmqr98WjYwUFBSwc+dOoqOjS06g1xMdHU1MTEyVzpGTk0NhYSGBgVeuecjPzycjI6PMh73lFxlIzcoHNCpg3ft9SSIyZJYkIkIIIWoti5KR1NRUDAYDISFld4QNCQkhMTGxSud49tlnCQ8PL5PQXG7WrFn4+/ubPyIiIiwJ0yoSi6doPFz11PV2s+/FT/wFP09Rb/eeCr0ftu/1hRBCCDuy62qaN998k++//57ly5fj6XnlOozp06eTnp5u/jh79qwdo1TFl1rWq7NnU7H43bBkLBiLoMOdcN3/2e/aQgghhAZcLTk4KCgIFxcXkpKSytyflJREaGhohY999913efPNN/nrr7/o2LFjhcd6eHjg4aFt+3VTw7PwqizrzUyCY6tAMdTsokYDbHgLCrOhyQC49RPQy+prIYQQtZtFyYi7uzvdunVj7dq1DB8+HFALWNeuXcvUqVOv+Li3336b119/ndWrV9O9u3NscW/Rbr0rJkPsWutdPLQDjPoGXN2td04hhBDCQVmUjABMmzaNcePG0b17d3r27Mns2bPJzs5m/PjxAIwdO5YGDRowa9YsAN566y1mzJjB4sWLiYyMNNeW1KlThzp16ljxR7Gu8+YeI5WMjORnwsmN6u2WN9S8PbtvKPR/Bjztt2pICCGE0JLFycioUaNISUlhxowZJCYm0rlzZ1atWmUuaj1z5gz6UlMLn376KQUFBYwYMaLMeWbOnMnLL79cs+htKOGSqftqJSMjJzeCsRDqNoG7v7dDZEIIIUTtYnEyAjB16tQrTsusX7++zNenTp2qziU0Z5qmqXRZ74m/1M/Nr7w6SAghhBBXJtWRVxBflWkaRZFkRAghhKghSUbKkZVfREZeEVDJNE3qcbh0Rm3V3qSfnaITQgghahdJRsphqhfx9XSljkcFM1mmUZHGfcDdxw6RCSGEELWPJCPliDfVi1S2rFemaIQQQogak2SkHKaRkQobnhXkwKlN6u3m19khKiGEEKJ2kmSkHKaRkQrrRU5vBkM++DWE+q3sFJkQQghR+0gyUo4qraQxT9EMBnvuXSOEEELUMpKMlMO0L02FreCPr1E/t5ApGiGEEKImJBkpR8KlShqepcVBWizoXaFJfztGJoQQQtQ+koxcRlEU4ivbsfdE8aZ4EVHg6W+nyIQQQojaSZKRy1zMKSSv0AhA6JVqRmRJrxBCCGE1koxcxlS8GlTHHQ/XcnbgLcov2aVXkhEhhBCixiQZuUylG+SdiYHCHKgTAqEd7BiZEEIIUTtJMnKZkpU0V5iiMa2iaR4tS3qFEEIIK5Bk5DLnL1WyrNdUvNp8sJ0iEkIIIWo3SUYuU7Kst5yRkfRzkHIYdHpoOsjOkQkhhBC1kyQjl0kwL+stZ2TEtIqmQXfwDrRjVEIIIUTtJcnIZeKLR0bKnaaRJb1CCCGE1UkyUorBqJCYcYVpGkMhxG1Qb0syIoQQQliNJCOlpGTmYzAquOh1BPteloyc3Qb5GeBdD8K7aBOgEEIIUQtJMlKKqQ18iK8HLvrLlu2apmiaXQt6edqEEEIIa5FX1VIq3CBP6kWEEEIIm5BkpBRTK/iwy5ORzCRI3Kfebib9RYQQQghrkmSkFPNuvZd3X40tbnQW1hnq1LdvUEIIIUQtJ8lIKQnmZb2XJSMyRSOEEELYjCQjpZTb8MxogNi/1dstrtMgKiGEEKJ2k2SklPPlFbCe3wW5F8HDX+28KoQQQgirkmSkWH6RgdSsfOCyaRrzkt6B4OJq/8CEEEKIWk6SkWJJ6Woi4uGqJ9DHveQbJ9aon5vLFI0QQghhC5KMFIsvVS+i0xU3PMu+oE7TADSXJb1CCCGELUgyUsxUvFpmiiZuHaBAcDvwC9cmMCGEEKKWk2SkWLm79R4vnqJpIUt6hRBCCFuRZKSYqfuqebdeo7Gk2Zn0FxFCCCFsRpKRYgnply3rTdwH2SngXgciemkYmRBCCFG7STJSzLwvjalmxLSKpskAcHW/wqOEEEIIUVOSjBQrmaYpHhk5YZqikVU0QgghhC1JMgJk5xeRkVcEFI+M5F6Cs9vUb0q9iBBCCGFTkoxQsqzX19MVX083iFsPigGCWkLdxtoGJ4QQQtRykoxQsqw33LSsV3bpFUIIIexGkhFKFa8GeIKiSL2IEEIIYUeSjADx6aUaniUfgsx4cPWExn01jkwIIYSo/SQZARJMK2n8PUumaCL7gZtXBY8SQgghhDVIMsJlDc+kXkQIIYSwK0lGKKkZaeBTBKdj1DslGRFCCCHs4qpPRhRFIb54aW+TzF1gLIS6kVCvmbaBCSGEEFeJqz4ZuZRTSF6hEYB6Cf+odzaPBp1Ow6iEEEKIq8dVn4yYRkWCfNxwjZN6ESGEEMLeJBkpbnjW3TcNLp0BF3d1JY0QQggh7OKqT0ZMreAHuuxV72jUGzzqaBiREEIIcXW56pMR88hI4U71DpmiEUIIIezqqk9GEtJz8aCAyKw96h0trtM0HiGEEOJqc9UnI/GXcumlP4yrMR/8GkD91lqHJIQQQlxVJBm5lMdA/R71i+aDZUmvEEIIYWdXdTJiMCokZeTRX79PvaO5TNEIIYQQ9nZVJyOpWfmEKYk00yeg6Fyg6QCtQxJCCCGuOld1MnL+Ui4DikdFdBFR4OmvcURCCCHE1adaycjHH39MZGQknp6eREVFsW3btgqPX7p0Ka1bt8bT05MOHTrw+++/VytYa0u4lMcAfXF/kRaypFcIIYTQgsXJyJIlS5g2bRozZ85k165ddOrUiSFDhpCcnFzu8Vu2bGH06NFMmDCB3bt3M3z4cIYPH86BAwdqHHxNJV1Mp4/+oPqF9BcRQgghNKFTFEWx5AFRUVH06NGDOXPmAGA0GomIiOCRRx7hueee+8/xo0aNIjs7m99++818X69evejcuTNz586t0jUzMjLw9/cnPT0dPz8/S8Kt0NffLmLM8UfJcgukzvRY0F/Vs1ZCCCGEVVX19duiV9+CggJ27txJdHTJKIJeryc6OpqYmJhyHxMTE1PmeIAhQ4Zc8XiA/Px8MjIyynzYQmjKJgASg/pKIiKEEEJoxKJX4NTUVAwGAyEhIWXuDwkJITExsdzHJCYmWnQ8wKxZs/D39zd/REREWBJmlbXI3ApAdqNBNjm/EEIIISrnkMMB06dPJz093fxx9uxZ619EUYjvOJW99W4ksMP11j+/EEIIIarE1ZKDg4KCcHFxISkpqcz9SUlJhIaGlvuY0NBQi44H8PDwwMPDw5LQLKfT0efWB4EHbXsdIYQQQlTIopERd3d3unXrxtq1a833GY1G1q5dS+/evct9TO/evcscD7BmzZorHi+EEEKIq4tFIyMA06ZNY9y4cXTv3p2ePXsye/ZssrOzGT9+PABjx46lQYMGzJo1C4DHHnuMAQMG8N5773HTTTfx/fffs2PHDj7//HPr/iRCCCGEcEoWJyOjRo0iJSWFGTNmkJiYSOfOnVm1apW5SPXMmTPoS61M6dOnD4sXL+bFF1/k+eefp0WLFqxYsYL27dtb76cQQgghhNOyuM+IFmzVZ0QIIYQQtmOTPiNCCCGEENYmyYgQQgghNCXJiBBCCCE0JcmIEEIIITQlyYgQQgghNCXJiBBCCCE0JcmIEEIIITQlyYgQQgghNCXJiBBCCCE0ZXE7eC2YmsRmZGRoHIkQQgghqsr0ul1Zs3enSEYyMzMBiIiI0DgSIYQQQlgqMzMTf3//K37fKfamMRqNxMfH4+vri06ns9p5MzIyiIiI4OzZs7LnjQ3J82w/8lzbhzzP9iHPs33Y8nlWFIXMzEzCw8PLbKJ7OacYGdHr9TRs2NBm5/fz85NfdDuQ59l+5Lm2D3me7UOeZ/uw1fNc0YiIiRSwCiGEEEJTkowIIYQQQlNXdTLi4eHBzJkz8fDw0DqUWk2eZ/uR59o+5Hm2D3me7cMRnmenKGAVQgghRO11VY+MCCGEEEJ7kowIIYQQQlOSjAghhBBCU5KMCCGEEEJTV3Uy8vHHHxMZGYmnpydRUVFs27ZN65BqlZdffhmdTlfmo3Xr1lqH5fQ2btzIsGHDCA8PR6fTsWLFijLfVxSFGTNmEBYWhpeXF9HR0Rw/flybYJ1cZc/1fffd95/f8aFDh2oTrJOaNWsWPXr0wNfXl+DgYIYPH87Ro0fLHJOXl8eUKVOoV68ederU4Y477iApKUmjiJ1TVZ7ngQMH/uf3edKkSXaJ76pNRpYsWcK0adOYOXMmu3btolOnTgwZMoTk5GStQ6tV2rVrR0JCgvlj06ZNWofk9LKzs+nUqRMff/xxud9/++23+fDDD5k7dy5bt27Fx8eHIUOGkJeXZ+dInV9lzzXA0KFDy/yOf/fdd3aM0Plt2LCBKVOm8O+//7JmzRoKCwu5/vrryc7ONh/zxBNP8Ouvv7J06VI2bNhAfHw8t99+u4ZRO5+qPM8AEydOLPP7/Pbbb9snQOUq1bNnT2XKlCnmrw0GgxIeHq7MmjVLw6hql5kzZyqdOnXSOoxaDVCWL19u/tpoNCqhoaHKO++8Y77v0qVLioeHh/Ldd99pEGHtcflzrSiKMm7cOOXWW2/VJJ7aKjk5WQGUDRs2KIqi/v66ubkpS5cuNR9z+PBhBVBiYmK0CtPpXf48K4qiDBgwQHnsscc0ieeqHBkpKChg586dREdHm+/T6/VER0cTExOjYWS1z/HjxwkPD6dp06bcc889nDlzRuuQarWTJ0+SmJhY5nfb39+fqKgo+d22kfXr1xMcHEyrVq2YPHkyFy5c0Dokp5aeng5AYGAgADt37qSwsLDM73Tr1q1p1KiR/E7XwOXPs8m3335LUFAQ7du3Z/r06eTk5NglHqfYKM/aUlNTMRgMhISElLk/JCSEI0eOaBRV7RMVFcXChQtp1aoVCQkJvPLKK/Tr148DBw7g6+urdXi1UmJiIkC5v9um7wnrGTp0KLfffjtNmjQhNjaW559/nhtuuIGYmBhcXFy0Ds/pGI1GHn/8cfr27Uv79u0B9Xfa3d2dgICAMsfK73T1lfc8A9x99900btyY8PBw9u3bx7PPPsvRo0f56aefbB7TVZmMCPu44YYbzLc7duxIVFQUjRs35ocffmDChAkaRiaEddx1113m2x06dKBjx440a9aM9evXM3jwYA0jc05TpkzhwIEDUltmY1d6nh988EHz7Q4dOhAWFsbgwYOJjY2lWbNmNo3pqpymCQoKwsXF5T/V2ElJSYSGhmoUVe0XEBBAy5YtOXHihNah1Fqm31/53dZG06ZNCQoKkt/xapg6dSq//fYb69ato2HDhub7Q0NDKSgo4NKlS2WOl9/p6rnS81yeqKgoALv8Pl+VyYi7uzvdunVj7dq15vuMRiNr166ld+/eGkZWu2VlZREbG0tYWJjWodRaTZo0ITQ0tMzvdkZGBlu3bpXfbTs4d+4cFy5ckN9xCyiKwtSpU1m+fDl///03TZo0KfP9bt264ebmVuZ3+ujRo5w5c0Z+py1Q2fNcnj179gDY5ff5qp2mmTZtGuPGjaN79+707NmT2bNnk52dzfjx47UOrdZ46qmnGDZsGI0bNyY+Pp6ZM2fi4uLC6NGjtQ7NqWVlZZV5p3Ly5En27NlDYGAgjRo14vHHH+e1116jRYsWNGnShJdeeonw8HCGDx+uXdBOqqLnOjAwkFdeeYU77riD0NBQYmNjeeaZZ2jevDlDhgzRMGrnMmXKFBYvXszPP/+Mr6+vuQ7E398fLy8v/P39mTBhAtOmTSMwMBA/Pz8eeeQRevfuTa9evTSO3nlU9jzHxsayePFibrzxRurVq8e+fft44okn6N+/Px07drR9gJqs4XEQH330kdKoUSPF3d1d6dmzp/Lvv/9qHVKtMmrUKCUsLExxd3dXGjRooIwaNUo5ceKE1mE5vXXr1inAfz7GjRunKIq6vPell15SQkJCFA8PD2Xw4MHK0aNHtQ3aSVX0XOfk5CjXX3+9Ur9+fcXNzU1p3LixMnHiRCUxMVHrsJ1Kec8voCxYsMB8TG5urvLwww8rdevWVby9vZXbbrtNSUhI0C5oJ1TZ83zmzBmlf//+SmBgoOLh4aE0b95cefrpp5X09HS7xKcrDlIIIYQQQhNXZc2IEEIIIRyHJCNCCCGE0JQkI0IIIYTQlCQjQgghhNCUJCNCCCGE0JQkI0IIIYTQlCQjQgghhNCUJCNCCCGE0JQkI0IIIYTQlCQjQgghhNCUJCNCCCGE0JQkI0IIIYTQ1P8DDRkN2WT01rEAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib import pyplot as plt\n", + "random_picker.metrics.to_pandas()['score'].plot(label=\"random\")\n", + "pytorch_picker.metrics.to_pandas()['score'].plot(label=\"pytorch\")\n", + "\n", + "plt.legend()\n", + "\n", + "print(f\"The final average score for the default policy, calculated over a rolling window, is: {pytorch_picker.metrics.to_pandas()['score'].iloc[-1]}\")\n", + "print(f\"The final average score for the random policy, calculated over a rolling window, is: {random_picker.metrics.to_pandas()['score'].iloc[-1]}\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/setup.py b/setup.py index 880aa4f..c2a8676 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ from setuptools import setup, find_packages -import os with open("README.md", "r", encoding="UTF-8") as fh: long_description = fh.read() diff --git a/src/learn_to_pick/__init__.py b/src/learn_to_pick/__init__.py index dcdb105..f15e95b 100644 --- a/src/learn_to_pick/__init__.py +++ b/src/learn_to_pick/__init__.py @@ -5,12 +5,9 @@ BasedOn, Embed, Featurizer, - ModelRepository, Policy, SelectionScorer, ToSelectFrom, - VwPolicy, - VwLogger, embed, ) from learn_to_pick.pick_best import ( @@ -22,6 +19,14 @@ ) +from learn_to_pick.vw.policy import VwPolicy +from learn_to_pick.vw.model_repository import ModelRepository +from learn_to_pick.vw.logger import VwLogger + +from learn_to_pick.pytorch.policy import PyTorchPolicy +from learn_to_pick.pytorch.feature_embedder import PyTorchFeatureEmbedder + + def configure_logger() -> None: logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -48,9 +53,11 @@ def configure_logger() -> None: "SelectionScorer", "AutoSelectionScorer", "Featurizer", - "ModelRepository", "Policy", + "PyTorchPolicy", + "PyTorchFeatureEmbedder", + "embed", + "ModelRepository", "VwPolicy", "VwLogger", - "embed", ] diff --git a/src/learn_to_pick/base.py b/src/learn_to_pick/base.py index 73cd9f8..7f8115a 100644 --- a/src/learn_to_pick/base.py +++ b/src/learn_to_pick/base.py @@ -10,15 +10,12 @@ List, Optional, Tuple, - Type, TypeVar, Union, - Callable, ) from learn_to_pick.metrics import MetricsTrackerAverage, MetricsTrackerRollingWindow -from learn_to_pick.model_repository import ModelRepository -from learn_to_pick.vw_logger import VwLogger + from learn_to_pick.features import Featurized, DenseFeatures, SparseFeatures from enum import Enum @@ -89,10 +86,6 @@ def EmbedAndKeep(anything: Any) -> Any: # helper functions -def _parse_lines(parser: "vw.TextFormatParser", input_str: str) -> List["vw.Example"]: - return [parser.parse_line(line) for line in input_str.split("\n")] - - def filter_inputs(inputs: Dict[str, Any], role: Role) -> Dict[str, Any]: return { k: v.value @@ -144,50 +137,6 @@ def save(self) -> None: pass -class VwPolicy(Policy): - def __init__( - self, - model_repo: ModelRepository, - vw_cmd: List[str], - featurizer: Featurizer, - formatter: Callable, - vw_logger: VwLogger, - **kwargs: Any, - ): - super().__init__(**kwargs) - self.model_repo = model_repo - self.vw_cmd = vw_cmd - self.workspace = self.model_repo.load(vw_cmd) - self.featurizer = featurizer - self.formatter = formatter - self.vw_logger = vw_logger - - def format(self, event): - return self.formatter(*self.featurizer.featurize(event)) - - def predict(self, event: TEvent) -> Any: - import vowpal_wabbit_next as vw - - text_parser = vw.TextFormatParser(self.workspace) - return self.workspace.predict_one(_parse_lines(text_parser, self.format(event))) - - def learn(self, event: TEvent) -> None: - import vowpal_wabbit_next as vw - - vw_ex = self.format(event) - text_parser = vw.TextFormatParser(self.workspace) - multi_ex = _parse_lines(text_parser, vw_ex) - self.workspace.learn_one(multi_ex) - - def log(self, event: TEvent) -> None: - if self.vw_logger.logging_enabled(): - vw_ex = self.format(event) - self.vw_logger.log(vw_ex) - - def save(self) -> None: - self.model_repo.save(self.workspace) - - class Featurizer(Generic[TEvent], ABC): def __init__(self, *args: Any, **kwargs: Any): pass diff --git a/src/learn_to_pick/pick_best.py b/src/learn_to_pick/pick_best.py index f51aedc..e574157 100644 --- a/src/learn_to_pick/pick_best.py +++ b/src/learn_to_pick/pick_best.py @@ -7,6 +7,10 @@ import numpy as np from learn_to_pick import base +from learn_to_pick.vw.policy import VwPolicy +from learn_to_pick.vw.model_repository import ModelRepository +from learn_to_pick.vw.logger import VwLogger + logger = logging.getLogger(__name__) @@ -333,14 +337,14 @@ def create_policy( vw_cmd = interactions + vw_cmd - return base.VwPolicy( - model_repo=base.ModelRepository( + return VwPolicy( + model_repo=ModelRepository( model_save_dir, with_history=True, reset=reset_model ), vw_cmd=vw_cmd, featurizer=featurizer, formatter=formatter, - vw_logger=base.VwLogger(rl_logs), + vw_logger=VwLogger(rl_logs), ) def _default_policy(self): diff --git a/src/learn_to_pick/pytorch/__init__.py b/src/learn_to_pick/pytorch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/learn_to_pick/pytorch/feature_embedder.py b/src/learn_to_pick/pytorch/feature_embedder.py new file mode 100644 index 0000000..7014c92 --- /dev/null +++ b/src/learn_to_pick/pytorch/feature_embedder.py @@ -0,0 +1,69 @@ +from sentence_transformers import SentenceTransformer +import torch +from torch import Tensor + +from learn_to_pick import PickBestFeaturizer +from learn_to_pick.base import Event +from learn_to_pick.features import SparseFeatures +from typing import Any, Tuple, TypeVar, Union + +TEvent = TypeVar("TEvent", bound=Event) + + +class PyTorchFeatureEmbedder: + def __init__(self, model: Any = None): + if model is None: + model = SentenceTransformer("all-MiniLM-L6-v2") + + self.model = model + self.featurizer = PickBestFeaturizer(auto_embed=False) + + def encode(self, to_encode: str) -> Tensor: + embeddings = self.model.encode(to_encode, convert_to_tensor=True) + normalized = torch.nn.functional.normalize(embeddings) + return normalized + + def convert_features_to_text(self, sparse_features: SparseFeatures) -> str: + results = [] + for ns, obj in sparse_features.items(): + value = obj.get("default_ft", "") + results.append(f"{ns}={value}") + return " ".join(results) + + def format( + self, event: TEvent + ) -> Union[Tuple[Tensor, Tensor, Tensor], Tuple[Tensor, Tensor]]: + context_featurized, actions_featurized, selected = self.featurizer.featurize( + event + ) + + if len(context_featurized.dense) > 0: + raise NotImplementedError( + "pytorch policy doesn't support context with dense features" + ) + + for action_featurized in actions_featurized: + if len(action_featurized.dense) > 0: + raise NotImplementedError( + "pytorch policy doesn't support action with dense features" + ) + + context_sparse = self.encode( + [self.convert_features_to_text(context_featurized.sparse)] + ) + + actions_sparse = [] + for action_featurized in actions_featurized: + actions_sparse.append( + self.convert_features_to_text(action_featurized.sparse) + ) + actions_sparse = self.encode(actions_sparse).unsqueeze(0) + + if selected.score is not None: + return ( + torch.Tensor([[selected.score]]), + context_sparse, + actions_sparse[:, selected.index, :].unsqueeze(1), + ) + else: + return context_sparse, actions_sparse diff --git a/src/learn_to_pick/pytorch/igw.py b/src/learn_to_pick/pytorch/igw.py new file mode 100644 index 0000000..f3d895c --- /dev/null +++ b/src/learn_to_pick/pytorch/igw.py @@ -0,0 +1,21 @@ +import torch +from torch import Tensor +from typing import Tuple + + +def IGW(fhat: torch.Tensor, gamma: float) -> Tuple[Tensor, Tensor]: + from math import sqrt + + fhatahat, ahat = fhat.max(dim=1) + A = fhat.shape[1] + gamma *= sqrt(A) + p = 1 / (A + gamma * (fhatahat.unsqueeze(1) - fhat)) + sump = p.sum(dim=1) + p[range(p.shape[0]), ahat] += torch.clamp(1 - sump, min=0, max=None) + return torch.multinomial(p, num_samples=1).squeeze(1), ahat + + +def SamplingIGW(A: Tensor, P: Tensor, gamma: float) -> list: + exploreind, _ = IGW(P, gamma) + explore = [ind for _, ind in zip(A, exploreind)] + return explore diff --git a/src/learn_to_pick/pytorch/logistic_regression.py b/src/learn_to_pick/pytorch/logistic_regression.py new file mode 100644 index 0000000..9e1e1f2 --- /dev/null +++ b/src/learn_to_pick/pytorch/logistic_regression.py @@ -0,0 +1,91 @@ +import parameterfree +import torch +from torch import Tensor +import torch.nn.functional as F + + +class MLP(torch.nn.Module): + @staticmethod + def new_gelu(x: Tensor) -> Tensor: + import math + + return ( + 0.5 + * x + * ( + 1.0 + + torch.tanh( + math.sqrt(2.0 / math.pi) * (x + 0.044715 * torch.pow(x, 3.0)) + ) + ) + ) + + def __init__(self, dim: int): + super().__init__() + self.c_fc = torch.nn.Linear(dim, 4 * dim) + self.c_proj = torch.nn.Linear(4 * dim, dim) + self.dropout = torch.nn.Dropout(0.5) + + def forward(self, x: Tensor) -> Tensor: + x = self.c_fc(x) + x = self.new_gelu(x) + x = self.c_proj(x) + x = self.dropout(x) + return x + + +class Block(torch.nn.Module): + def __init__(self, dim: int): + super().__init__() + self.layer = MLP(dim) + + def forward(self, x: Tensor): + return x + self.layer(x) + + +class ResidualLogisticRegressor(torch.nn.Module): + def __init__(self, in_features: int, depth: int, device: str): + super().__init__() + self._in_features = in_features + self._depth = depth + self.blocks = torch.nn.Sequential(*[Block(in_features) for _ in range(depth)]) + self.linear = torch.nn.Linear(in_features=in_features, out_features=1) + self.optim = parameterfree.COCOB(self.parameters()) + self._device = device + + def clone(self) -> "ResidualLogisticRegressor": + other = ResidualLogisticRegressor(self._in_features, self._depth, self._device) + other.load_state_dict(self.state_dict()) + other.optim = parameterfree.COCOB(other.parameters()) + other.optim.load_state_dict(self.optim.state_dict()) + return other + + def forward(self, X: Tensor, A: Tensor) -> Tensor: + return self.logits(X, A) + + def logits(self, X: Tensor, A: Tensor) -> Tensor: + # X = batch x features + # A = batch x actionbatch x actionfeatures + + Xreshap = X.unsqueeze(1).expand( + -1, A.shape[1], -1 + ) # batch x actionbatch x features + XA = ( + torch.cat((Xreshap, A), dim=-1) + .reshape(X.shape[0], A.shape[1], -1) + .to(self._device) + ) # batch x actionbatch x (features + actionfeatures) + return self.linear(self.blocks(XA)).squeeze(2) # batch x actionbatch + + def predict(self, X: Tensor, A: Tensor) -> Tensor: + self.eval() + return torch.special.expit(self.logits(X, A)) + + def bandit_learn(self, X: Tensor, A: Tensor, R: Tensor) -> float: + self.train() + self.optim.zero_grad() + output = self(X, A) + loss = F.binary_cross_entropy_with_logits(output, R) + loss.backward() + self.optim.step() + return loss.item() diff --git a/src/learn_to_pick/pytorch/policy.py b/src/learn_to_pick/pytorch/policy.py new file mode 100644 index 0000000..6848e47 --- /dev/null +++ b/src/learn_to_pick/pytorch/policy.py @@ -0,0 +1,85 @@ +from learn_to_pick import base, PickBestEvent +from learn_to_pick.pytorch.logistic_regression import ResidualLogisticRegressor +from learn_to_pick.pytorch.igw import SamplingIGW +from learn_to_pick.pytorch.feature_embedder import PyTorchFeatureEmbedder +import torch +import os +from typing import Any, Optional, TypeVar, Union + +TEvent = TypeVar("TEvent", bound=base.Event) + + +class PyTorchPolicy(base.Policy[PickBestEvent]): + def __init__( + self, + feature_embedder=PyTorchFeatureEmbedder(), + depth: int = 2, + device: str = "cuda" if torch.cuda.is_available() else "cpu", + *args: Any, + **kwargs: Any, + ): + print(f"Device: {device}") + super().__init__(*args, **kwargs) + self.workspace = ResidualLogisticRegressor( + feature_embedder.model.get_sentence_embedding_dimension() * 2, depth, device + ).to(device) + self.feature_embedder = feature_embedder + self.device = device + self.index = 0 + self.loss = None + + def predict(self, event: TEvent) -> list: + X, A = self.feature_embedder.format(event) + # TODO IGW sampling then create the distro so that the one + # that was sampled here is the one that will def be sampled by + # the base sampler, and in the future replace the sampler so that it + # is something that can be plugged in + p = self.workspace.predict(X, A) + import math + + explore = SamplingIGW(A, p, math.sqrt(self.index)) + self.index += 1 + r = [] + for index in range(p.shape[1]): + if index == explore[0]: + r.append((index, 1)) + else: + r.append((index, 0)) + return r + + def learn(self, event: TEvent) -> None: + R, X, A = self.feature_embedder.format(event) + R, X, A = R.to(self.device), X.to(self.device), A.to(self.device) + self.loss = self.workspace.bandit_learn(X, A, R) + + def log(self, event): + pass + + def save(self, path: Optional[Union[str, os.PathLike]]) -> None: + state = { + "workspace_state_dict": self.workspace.state_dict(), + "optimizer_state_dict": self.workspace.optim.state_dict(), + "device": self.device, + "index": self.index, + "loss": self.loss, + } + print(f"Saving model to {path}") + dir, _ = os.path.split(path) + if dir and not os.path.exists(dir): + os.makedirs(dir, exist_ok=True) + torch.save(state, path) + + def load(self, path: Optional[Union[str, os.PathLike]]) -> None: + import parameterfree + + if os.path.exists(path): + print(f"Loading model from {path}") + checkpoint = torch.load(path, map_location=self.device) + + self.workspace.load_state_dict(checkpoint["workspace_state_dict"]) + self.workspace.optim = parameterfree.COCOB(self.workspace.parameters()) + self.workspace.optim.load_state_dict(checkpoint["optimizer_state_dict"]) + self.device = checkpoint["device"] + self.workspace.to(self.device) + self.index = checkpoint["index"] + self.loss = checkpoint["loss"] diff --git a/src/learn_to_pick/vw/__init__.py b/src/learn_to_pick/vw/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/learn_to_pick/vw_logger.py b/src/learn_to_pick/vw/logger.py similarity index 100% rename from src/learn_to_pick/vw_logger.py rename to src/learn_to_pick/vw/logger.py diff --git a/src/learn_to_pick/model_repository.py b/src/learn_to_pick/vw/model_repository.py similarity index 100% rename from src/learn_to_pick/model_repository.py rename to src/learn_to_pick/vw/model_repository.py diff --git a/src/learn_to_pick/vw/policy.py b/src/learn_to_pick/vw/policy.py new file mode 100644 index 0000000..5bd71c2 --- /dev/null +++ b/src/learn_to_pick/vw/policy.py @@ -0,0 +1,57 @@ +from learn_to_pick.base import Event, Featurizer, Policy +from learn_to_pick.vw.model_repository import ModelRepository +from learn_to_pick.vw.logger import VwLogger +from typing import Any, List, Callable, TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + import vowpal_wabbit_next as vw + +TEvent = TypeVar("TEvent", bound=Event) + + +def _parse_lines(parser: "vw.TextFormatParser", input_str: str) -> List["vw.Example"]: + return [parser.parse_line(line) for line in input_str.split("\n")] + + +class VwPolicy(Policy): + def __init__( + self, + model_repo: ModelRepository, + vw_cmd: List[str], + featurizer: Featurizer, + formatter: Callable, + vw_logger: VwLogger, + **kwargs: Any, + ): + super().__init__(**kwargs) + self.model_repo = model_repo + self.vw_cmd = vw_cmd + self.workspace = self.model_repo.load(vw_cmd) + self.featurizer = featurizer + self.formatter = formatter + self.vw_logger = vw_logger + + def format(self, event): + return self.formatter(*self.featurizer.featurize(event)) + + def predict(self, event: TEvent) -> Any: + import vowpal_wabbit_next as vw + + text_parser = vw.TextFormatParser(self.workspace) + return self.workspace.predict_one(_parse_lines(text_parser, self.format(event))) + + def learn(self, event: TEvent) -> None: + import vowpal_wabbit_next as vw + + vw_ex = self.format(event) + text_parser = vw.TextFormatParser(self.workspace) + multi_ex = _parse_lines(text_parser, vw_ex) + self.workspace.learn_one(multi_ex) + + def log(self, event: TEvent) -> None: + if self.vw_logger.logging_enabled(): + vw_ex = self.format(event) + self.vw_logger.log(vw_ex) + + def save(self) -> None: + self.model_repo.save(self.workspace) diff --git a/tests/unit_tests/test_pytorch_model.py b/tests/unit_tests/test_pytorch_model.py new file mode 100644 index 0000000..147ec00 --- /dev/null +++ b/tests/unit_tests/test_pytorch_model.py @@ -0,0 +1,134 @@ +import random +import torch +import os +import pytest +import shutil + +import learn_to_pick + + +CHECKPOINT_DIR = "test_models" + + +@pytest.fixture +def remove_checkpoint(): + yield + if os.path.isdir(CHECKPOINT_DIR): + shutil.rmtree(CHECKPOINT_DIR) + + +class CustomSelectionScorer(learn_to_pick.SelectionScorer): + def get_score(self, user, time_of_day, article): + preferences = { + "Tom": {"morning": "politics", "afternoon": "music"}, + "Anna": {"morning": "sports", "afternoon": "politics"}, + } + + return int(preferences[user][time_of_day] == article) + + def score_response( + self, inputs, picked, event: learn_to_pick.PickBestEvent + ) -> float: + chosen_article = picked["article"] + user = event.based_on["user"] + time_of_day = event.based_on["time_of_day"] + score = self.get_score(user, time_of_day, chosen_article) + return score + + +class Simulator: + def __init__(self, seed=7492381): + self.random = random.Random(seed) + self.users = ["Tom", "Anna"] + self.times_of_day = ["morning", "afternoon"] + self.articles = ["politics", "sports", "music"] + + def _choose_user(self): + return self.random.choice(self.users) + + def _choose_time_of_day(self): + return self.random.choice(self.times_of_day) + + def run(self, pytorch_picker, T): + for i in range(T): + user = self._choose_user() + time_of_day = self._choose_time_of_day() + pytorch_picker.run( + article=learn_to_pick.ToSelectFrom(self.articles), + user=learn_to_pick.BasedOn(user), + time_of_day=learn_to_pick.BasedOn(time_of_day), + ) + + +def verify_same_models(model1, model2): + for p1, p2 in zip(model1.parameters(), model2.parameters()): + assert torch.equal(p1, p2), "The models' parameters are not equal." + + for (name1, buffer1), (name2, buffer2) in zip( + model1.named_buffers(), model2.named_buffers() + ): + assert name1 == name2, "Buffer names do not match." + assert torch.equal(buffer1, buffer2), f"The buffers {name1} are not equal." + + +def verify_same_optimizers(optimizer1, optimizer2): + if type(optimizer1) != type(optimizer2): + return False + + if optimizer1.defaults != optimizer2.defaults: + return False + + state_dict1 = optimizer1.state_dict() + state_dict2 = optimizer2.state_dict() + + if state_dict1.keys() != state_dict2.keys(): + return False + + for key in state_dict1: + if key == "state": + if state_dict1[key].keys() != state_dict2[key].keys(): + return False + for subkey in state_dict1[key]: + if not torch.equal(state_dict1[key][subkey], state_dict2[key][subkey]): + return False + else: + if state_dict1[key] != state_dict2[key]: + return False + + return True + + +def test_save_load(remove_checkpoint): + sim1 = Simulator() + sim2 = Simulator() + + first_model_path = f"{CHECKPOINT_DIR}/first.checkpoint" + + torch.manual_seed(0) + first_policy = learn_to_pick.PyTorchPolicy() + other_policy = learn_to_pick.PyTorchPolicy() + + torch.manual_seed(0) + + first_picker = learn_to_pick.PickBest.create( + policy=first_policy, selection_scorer=CustomSelectionScorer() + ) + sim1.run(first_picker, 5) + first_policy.save(first_model_path) + + other_policy.load(first_model_path) + other_picker = learn_to_pick.PickBest.create( + policy=other_policy, selection_scorer=CustomSelectionScorer() + ) + sim1.run(other_picker, 5) + + torch.manual_seed(0) + all_policy = learn_to_pick.PyTorchPolicy() + torch.manual_seed(0) + all_picker = learn_to_pick.PickBest.create( + policy=all_policy, selection_scorer=CustomSelectionScorer() + ) + sim2.run(all_picker, 10) + + verify_same_models(other_policy.workspace, all_policy.workspace) + verify_same_optimizers(other_policy.workspace.optim, all_policy.workspace.optim)