├── .gitignore
├── LICENSE
├── data
├── N-MNIST
│ └── .gitignore
├── SHD
│ └── .gitignore
└── ephys
│ └── .gitignore
├── docs
└── README.md
├── environment.yml
├── figures
├── figure2.png
├── figure4.png
└── figure5.png
├── notebooks
├── ephys.ipynb
├── getting_started.ipynb
├── library_comparison.ipynb
├── speed_benchmarks.ipynb
└── supervised_benchmarks.ipynb
├── scripts
├── ephys
│ ├── build_data.py
│ ├── run.py
│ └── train.py
├── run_benchmarks.py
└── supervised
│ ├── run_blocks_nmnist.py
│ ├── run_blocks_shd.py
│ ├── run_detach_spikes.py
│ ├── run_standard_nmnist.py
│ ├── run_standard_shd.py
│ └── train.py
├── setup.py
├── src
├── __init__.py
├── benchmark.py
├── datasets
│ ├── __init__.py
│ ├── ephys.py
│ ├── neuromorphic.py
│ ├── synthetic.py
│ └── transforms.py
├── metric.py
├── models.py
├── query.py
├── snn
│ ├── __init__.py
│ ├── block
│ │ ├── __init__.py
│ │ ├── block.py
│ │ ├── blocks.py
│ │ └── util.py
│ ├── snn.py
│ └── surrogate.py
└── train.py
└── tests
├── __init__.py
├── test_block.py
└── test_blocks.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | .DS_Store
3 |
4 | # Python
5 | __pycache__/
6 | *.pyc
7 | src.egg-info
8 |
9 | # PyCharm
10 | .idea
11 |
12 | # Jupyter Notebook
13 | .ipynb_checkpoints
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2023, Luke Taylor
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/data/N-MNIST/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore everything in this directory
2 | *
3 | # Except this file
4 | !.gitignore
--------------------------------------------------------------------------------
/data/SHD/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore everything in this directory
2 | *
3 | # Except this file
4 | !.gitignore
--------------------------------------------------------------------------------
/data/ephys/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore everything in this directory
2 | *
3 | # Except this file
4 | !.gitignore
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Addressing the speed-accuracy simulation trade-off for adaptive spiking neurons
2 |
3 | A new model for quickly training and simulating adaptive leaky integrate-and-fire spiking neural networks.
4 |
5 |
6 |
7 |
8 |
9 | ## Installing dependencies
10 |
11 | Install all required dependencies and activate the blocks environment using conda.
12 | ```
13 | conda env create -f environment.yml
14 | conda activate blocks
15 | ```
16 |
17 | ## Getting started tutorial
18 |
19 | See the [notebooks/getting_started.ipynb](../notebooks/getting_started.ipynb) notebook for getting started with our model.
20 |
21 | ## Reproducing paper results
22 |
23 | All the paper results can be reproduced using the scripts available in the `scripts` folder.
24 |
25 | ### Running benchmark experiments
26 |
27 | The `python run_benchmarks.py` script will benchmark the time of the forward and backward passes of the blocks and the standard SNN model for different numbers of neurons and simulation steps.
28 |
29 | ### Training models
30 |
31 | Ensure that the computer has a CUDA capable GPU with CUDA 11.7 installed.
32 |
33 | #### 1. Downloading and processing datasets
34 |
35 | #### Machine learning datasets:
36 | The content of the Neuromorphic-MNIST dataset can be [downloaded](https://www.garrickorchard.com/datasets/n-mnist) and unzipped into the `data/N-MNIST` directory. Thereafter, the `python convert_nmnist2h5.py` script (adapted from Perez-Nieves et al., 2021) needs to be run which processes the raw dataset. The Spiking Heidelberg Digits (SHD) dataset can be [downloaded](https://compneuro.net/posts/2019-spiking-heidelberg-digits/) and unzipped into the `data/SHD` directory.
37 |
38 | #### E-phys dataset:
39 |
40 | Running the `scripts/ephys/build_data.py` script will download and process the necessary data from the Allen Institute.
41 |
42 | #### 2. Train model
43 |
44 | You can train the blocks and standard SNN on the different datasets using the train.py scripts in the `scripts/ephys` and `scripts/supervised` folders respectively. See respective folders for different experiment run scripts.
45 |
46 | ## Building result figures
47 |
48 | Speedup plots can be built using: `notebooks/results/speed_benchmarks.ipynb`
49 |
50 | Machine learning benchmark plots can be built using: `notebooks/results/supervised_benchmarks.ipynb`
51 |
52 | Neural-fitting plots can be built using: `notebooks/results/ephys.ipynb`
53 |
54 | ### Machine learning benchmark results
55 |
56 |
57 |
58 |
59 | ### Neural-fitting results
60 |
61 |
62 |
--------------------------------------------------------------------------------
/environment.yml:
--------------------------------------------------------------------------------
1 | name: blocks
2 | dependencies:
3 | - python=3.8
4 | - pytorch::pytorch
5 | - pytorch::torchvision
6 | - nvidia::cudatoolkit=11.7
7 | - matplotlib
8 | - seaborn
9 | - pandas
10 | - conda-forge::h5py
11 | - nb_conda_kernels
12 | - ipywidgets
13 | - pytest
14 | - pip:
15 | - brainbox==0.0.6
16 | - jupyter
17 | - allensdk
18 | - --editable .
19 |
--------------------------------------------------------------------------------
/figures/figure2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webstorms/Blocks/540c9f28eedd58ef638dcacf75b4c27cdf52baa0/figures/figure2.png
--------------------------------------------------------------------------------
/figures/figure4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webstorms/Blocks/540c9f28eedd58ef638dcacf75b4c27cdf52baa0/figures/figure4.png
--------------------------------------------------------------------------------
/figures/figure5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webstorms/Blocks/540c9f28eedd58ef638dcacf75b4c27cdf52baa0/figures/figure5.png
--------------------------------------------------------------------------------
/notebooks/getting_started.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "code",
5 | "execution_count": 89,
6 | "id": "da7df381",
7 | "metadata": {},
8 | "outputs": [
9 | {
10 | "name": "stdout",
11 | "output_type": "stream",
12 | "text": [
13 | "The autoreload extension is already loaded. To reload it, use:\n",
14 | " %reload_ext autoreload\n"
15 | ]
16 | }
17 | ],
18 | "source": [
19 | "import time\n",
20 | "\n",
21 | "import torch\n",
22 | "import numpy as np\n",
23 | "import matplotlib.pyplot as plt\n",
24 | "import seaborn as sns\n",
25 | "import pandas as pd\n",
26 | "\n",
27 | "from src.datasets import SyntheticSpikes\n",
28 | "from src.snn.snn import SNN\n",
29 | "from src.snn.block.blocks import Blocks\n",
30 | "\n",
31 | "%load_ext autoreload\n",
32 | "%autoreload 2"
33 | ]
34 | },
35 | {
36 | "cell_type": "markdown",
37 | "id": "8702a126",
38 | "metadata": {},
39 | "source": [
40 | "## Model equivalence"
41 | ]
42 | },
43 | {
44 | "cell_type": "markdown",
45 | "id": "ca0f57e9",
46 | "metadata": {},
47 | "source": [
48 | "Let's check if the blocks model and the standard model produce the same output raster"
49 | ]
50 | },
51 | {
52 | "cell_type": "code",
53 | "execution_count": 86,
54 | "id": "286f166a",
55 | "metadata": {},
56 | "outputs": [],
57 | "source": [
58 | "n_in = 100\n",
59 | "n_out = 20\n",
60 | "rf_len = 20\n",
61 | "t_len = 1000 \n",
62 | "block_len = 40\n",
63 | "\n",
64 | "# Instantiate recurrently connected ALIF SNNs (blocks and standard version)\n",
65 | "torch.manual_seed(42)\n",
66 | "input_raster = SyntheticSpikes(t_len, n_in, min_r=0, max_r=100, n_samples=1)[0]\n",
67 | "standard_snn = SNN(n_in, n_out, rf_len, t_len, t_latency=block_len, recurrent=True, init_beta=0.99, init_p=0.99)\n",
68 | "blocks_snn = Blocks(n_in, n_out, rf_len, t_len, t_latency=block_len, recurrent=True, init_beta=0.99, init_p=0.99)\n",
69 | "\n",
70 | "# Ensure bocks model has the same weights as the standard model\n",
71 | "blocks_snn._rf_weight = standard_snn._rf_weight\n",
72 | "blocks_snn._rf_bias = standard_snn._rf_bias\n",
73 | "blocks_snn._rec_weight = standard_snn._rec_weight\n",
74 | "\n",
75 | "# Obtain spikes from blocks and standard model\n",
76 | "with torch.no_grad():\n",
77 | " blocks_spikes = blocks_snn(input_raster.unsqueeze(0), mode=\"train\")\n",
78 | " standard_spikes = standard_snn(input_raster.unsqueeze(0), mode=\"train\")"
79 | ]
80 | },
81 | {
82 | "cell_type": "code",
83 | "execution_count": 62,
84 | "id": "3c475ec8",
85 | "metadata": {},
86 | "outputs": [
87 | {
88 | "name": "stdout",
89 | "output_type": "stream",
90 | "text": [
91 | "Are model outputs the same? True\n"
92 | ]
93 | }
94 | ],
95 | "source": [
96 | "# Drum roll, the moment we have all been waiting for...\n",
97 | "print(f\"Are model outputs the same? {torch.allclose(blocks_spikes, standard_spikes)}\")"
98 | ]
99 | },
100 | {
101 | "cell_type": "code",
102 | "execution_count": 88,
103 | "id": "e476bd42",
104 | "metadata": {
105 | "scrolled": true
106 | },
107 | "outputs": [
108 | {
109 | "data": {
110 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAk4AAAEiCAYAAAAPh11JAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjQuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8rg+JYAAAACXBIWXMAAA9hAAAPYQGoP6dpAADUPklEQVR4nOydf3xT1f3/Xzc3P2560962aUpvA6Q0EAEToEpt4lB0xdyqqDg3PtpusrjxQ6fO7uvq3By2HU6mTpz4Gej2UVsnm4pMtvGRisPhpti1E22ZKD/Cj1IKttEWSmlL2/P9g889S25v2gJFwJ7n43EfkHvP73PSnLzyPu83RwghYDAYDAaDwWAMiuFsN4DBYDAYDAbjfIFtnBgMBoPBYDCGCNs4MRgMBoPBYAwRtnFiMBgMBoPBGCJs48RgMBgMBoMxRNjGicFgMBgMBmOIsI0Tg8FgMBgMxhBhGycGg8FgMBiMIcI2TgwGg8FgMBhDhG2cGAzGacFxHEpLS89I2X/729/AcRxWr159RspnMM4lsrKy8O1vf/sLqevb3/42srKyvpC6vmywjdNp8Pzzz4PjONTW1p7tpgAAOjo6UFpair/97W9nuykx/O///u8Z+2BlDD/quo6+0tPTceWVV+L1118/28075/n1r3+N559//gup66OPPkJpaSn27NnzhdR3PlJfX4+vf/3rcLlcEAQBTqcTV111FZYvXx6T7uc//zlee+21s9PIEcSBAwdQWlqKDz744Aupb9WqVXjiiSeGtUy2cfoS0dHRgbKysnNy41RWVna2m8E4ScrLy/HCCy+gsrISJSUlaG5uxjXXXIO//OUvZ7tp5zRf9MaprKyMbZzi8O6772L69On48MMPMX/+fDz11FP47ne/C4PBgF/96lcxadnG6YvhwIEDKCsrO683TsZhLY1x3nP06FGIoni2mzEohBB0dnbCarWe7aZ8abn66qsxffp0+vo73/kORo0ahd///veYPXv2WWwZgzE0HnroIUiShJqaGiQnJ8c8+/TTT89Oo74gOjs7YTabYTAwfWS4YSM6zHz729+GzWZDY2Mj5syZA5vNBofDgXvvvRe9vb003Z49e8BxHB577DEsW7YMLpcLVqsVM2fOxNatW2PKvOKKK3DFFVfo1qX+Rr1nzx44HA4AQFlZGf2JZaCfyNSfZDZt2oQ77rgD6enpGD16NABg7969uOOOO3DBBRfAarXCbrfjG9/4Rr9vtsePH0dZWRkmTJgAQRBgt9sxY8YMbNiwgbbxv//7vwEg5qcflb6+PjzxxBO48MILIQgCRo0ahYULF+Lzzz+PqScrKwuzZ89GVVUVpk+fDqvViqeffjr+RDCGneTkZFitVhiNg3/f2rJlC66++mokJSXBZrMhPz8f7733Xr90ra2tKC4uRlZWFiwWC0aPHo1bb70VLS0tccvu6urC7NmzIUkS3n33XQDAkSNHcM8999By0tPTcdVVV+H9998flraWlpbGrFsV9T2kvi+ysrLw73//G5s2baJrXX3vqmnffvttLFy4EHa7HUlJSbj11lv7rfd4791oG5jnn38e3/jGNwAAV155Ja3vXFOczya7du3ChRde2G/TBADp6en0/xzH4ejRo6ioqKDjqI7zUP8WqvP7zjvv4Ac/+AEcDgdEUcSNN96I5ubmmLSEECxZsgSjR49GQkICrrzySvz73//u18bPPvsM9957L3w+H2w2G5KSknD11Vfjww8/jEmn2gL+4Q9/wAMPPACn04mEhAQcPnwYAPDaa6/B6/VCEAR4vV788Y9/PKlx/PWvf40LL7wQFosFmZmZ+N73vofW1taYNPHss6I/v/72t78hNzcXABAKhehYqwrtFVdcAa/Xi3/961+49NJLYbVaMW7cOKxcuTKmTO37TjsO6nvgiiuuwLp167B3715a13DYdTHF6QzQ29sLRVGQl5eHxx57DG+++SZ++ctfwu124/bbb49JW1lZiSNHjuB73/seOjs78atf/Qpf/epXUV9fj1GjRg25TofDgRUrVuD222/HjTfeiK997WsAgClTpgya94477oDD4cDixYtx9OhRAEBNTQ3effdd3HzzzRg9ejT27NmDFStW4IorrsBHH32EhIQEACc+UB5++GF897vfxSWXXILDhw+jtrYW77//Pq666iosXLgQBw4cwIYNG/DCCy/0q3vhwoV4/vnnEQqFcPfdd2P37t146qmnsGXLFrzzzjswmUw07SeffIJbbrkFCxcuxPz583HBBRcMeXwYJ09bWxtaWlpACMGnn36K5cuXo729Hd/85jcHzPfvf/8bl112GZKSklBSUgKTyYSnn34aV1xxBTZt2oS8vDwAQHt7Oy677DJs27YNt912Gy666CK0tLTgT3/6E/bv34+0tLR+ZR87dgw33HADamtr8eabb9I/wosWLcLq1atx5513YvLkyYhEIvjHP/6Bbdu24aKLLjrttg6VJ554AnfddRdsNht+8pOfAEC/9/Gdd96J5ORklJaW4pNPPsGKFSuwd+9e+kd/qFx++eW4++678eSTT+LHP/4xJk2aBAD0XwbgcrmwefNmbN26FV6vN266F154gf4NW7BgAQDA7XYDGPrfQpW77roLKSkpePDBB7Fnzx488cQTuPPOO/HSSy/RNIsXL8aSJUtwzTXX4JprrsH777+PYDCI7u7umLLC4TBee+01fOMb38C4ceNw6NAhPP3005g5cyY++ugjZGZmxqT/2c9+BrPZjHvvvRddXV0wm8144403cNNNN2Hy5Ml4+OGHEYlEEAqF6JfkwSgtLUVZWRlmzZqF22+/na7Zmpqafn+jB2PSpEkoLy/H4sWLsWDBAlx22WUAgEsvvZSm+fzzz3HNNddg7ty5uOWWW/Dyyy/j9ttvh9lsxm233TbkugDgJz/5Cdra2rB//34sW7YMAGCz2U6qDF0I45R57rnnCABSU1ND782bN48AIOXl5TFpc3JyyMUXX0xf7969mwAgVquV7N+/n96vrq4mAEhxcTG9N3PmTDJz5sx+9c+bN4+4XC76urm5mQAgDz744Em1f8aMGaSnpyfmWUdHR7/0mzdvJgBIZWUlvTd16lRy7bXXDljP9773PaK31P7+978TAOTFF1+Mub9+/fp+910uFwFA1q9fP6S+MU4ddV1oL4vFQp5//vl+6bVrbs6cOcRsNpNdu3bRewcOHCCJiYnk8ssvp/cWL15MAJA1a9b0K7Ovr48QQshbb71FAJBXXnmFHDlyhMycOZOkpaWRLVu2xKSXJIl873vfO+m+DrWtDz74oO4aVsdq9+7d9N6FF16o+35V01588cWku7ub3n/kkUcIALJ27Vp6L9772OVykXnz5tHXr7zyCgFA3nrrraF1eITxxhtvEJ7nCc/zJBAIkJKSElJVVRUz/iqiKMaMrcpQ/xaq8ztr1iy6fgkhpLi4mPA8T1pbWwkhhHz66afEbDaTa6+9Nibdj3/8YwIgpg2dnZ2kt7c3pu7du3cTi8US8xmjvk+ys7P7tXfatGlElmVavzouAGI+P/RQ2xoMBmPa8dRTTxEA5Nlnn6X3tGtTRfv5VVNTQwCQ5557TjctAPLLX/6S3uvq6iLTpk0j6enpdN703nfR4xD9frj22msH7efJwn6qO0MsWrQo5vVll12GcDjcL92cOXPgdDrp60suuQR5eXn43//93zPeRpX58+eD5/mYe9G2Q8ePH0ckEsH48eORnJwc8/NHcnIy/v3vf2PHjh0nXe8rr7wCSZJw1VVXoaWlhV4XX3wxbDYb3nrrrZj048aNg6IoJ10P49T47//+b2zYsAEbNmzA7373O1x55ZX47ne/izVr1sTN09vbizfeeANz5sxBdnY2vS/LMgoLC/GPf/yD/nzw6quvYurUqbjxxhv7laNVXtra2hAMBvHxxx/jb3/7G6ZNmxbzPDk5GdXV1Thw4MCQ+3cybR1OFixYEPMt/fbbb4fRaPxC3/MjhauuugqbN2/G9ddfjw8//BCPPPIIFEWB0+nEn/70pyGVMdS/hSoLFiyIWb+XXXYZent7sXfvXgDAm2++ie7ubtx1110x6e65555+ZVksFmqj1Nvbi0gkApvNhgsuuEC37nnz5sW0t6mpCR988AHmzZsHSZJixmXy5MmD9l1t6z333BNjKzV//nwkJSVh3bp1g5ZxshiNRixcuJC+NpvNWLhwIT799FP861//Gvb6TgW2cToDCIJA7Y1UUlJS+tkxAMCECRP63fN4PF/oKZlx48b1u3fs2DEsXrwYY8aMgcViQVpaGhwOB1pbW9HW1kbTlZeXo7W1FR6PBz6fDz/84Q9RV1c3pHp37NiBtrY2pKenw+FwxFzt7e39jDf12sk4c1xyySWYNWsWZs2ahaKiIqxbtw6TJ0/GnXfe2e8nBZXm5mZ0dHTo/ow6adIk9PX1oaGhAcAJ+5OBfj6J5p577kFNTQ3efPNNXHjhhf2eP/LII9i6dSvGjBmDSy65BKWlpbpfVE61rcOJ9j1vs9kgyzI7GXeGyM3NxZo1a/D555/jn//8J+6//34cOXIEX//61/HRRx8Nmn+ofwtVxo4dG/M6JSUFAOjff3UDpV0HDoeDplXp6+vDsmXLMGHChJi66+rqdOvW/o2MVxeAIZk6qPm1ac1mM7Kzs+nz4SQzM7PfASWPxwMA58x7hG2czgBa9eZ0iWf3EG1sfjronUy766678NBDD2Hu3Ll4+eWX8cYbb2DDhg2w2+3o6+uj6S6//HLs2rULzz77LLxeL37729/ioosuwm9/+9tB6+3r60N6ejpVNbRXeXn5oO1kfHEYDAZceeWVaGpqOiWF8XS44YYbQAjB0qVLY9afyty5cxEOh7F8+XJkZmbi0UcfxYUXXjhsfqfO9HtwqHzR9X2ZMJvNyM3Nxc9//nOsWLECx48fxyuvvDJovqH+LVSJ9/efEHLSbf75z3+OH/zgB7j88svxu9/9DlVVVdiwYQMuvPBC3brP5t/IL/I9crbfj8w4/Cyj9wG0ffv2GMv/lJQU3W/P2t3+yRiWDsbq1asxb948/PKXv6T3Ojs7+52kAIDU1FSEQiGEQiG0t7fj8ssvR2lpKb773e8O2C63240333wTX/nKV9im6Dyhp6cHwAnDbj0cDgcSEhLwySef9Hv28ccfw2AwYMyYMQBOzL/2BGk85syZg2AwiG9/+9tITEzEihUr+qWRZRl33HEH7rjjDnz66ae46KKL8NBDD+Hqq68+7baqSkBra2vMCS29b9yDvQ937NiBK6+8kr5ub29HU1MTrrnmGnovJSWl33utu7sbTU1NJ1UXQx/VzUb0eMYby5P5WzgUXC4XgBPrIPon4ubm5n6/SqxevRpXXnkl/ud//ifmfmtrq+7hiYHq0qK37uPl/+STT2La2t3djd27d2PWrFn0nt6aBU68R6LzDrZmDxw40M8tzvbt2wGAfi5Gvx+1dWk5E+8RpjidZV577TU0NjbS1//85z9RXV0d88fe7Xbj448/jjnS+uGHH+Kdd96JKUs93XGqb+hoeJ7v9w1p+fLl/Xb0kUgk5rXNZsP48ePR1dVF76lvAG275s6di97eXvzsZz/rV39PT8+w9IMxfBw/fhxvvPEGzGZz3JNbPM8jGAxi7dq1MbL6oUOHsGrVKsyYMQNJSUkAgJtuugkffvih7tFovW/nt956K5588kmsXLkS9913H73f29vb72eL9PR0ZGZmxqzD02mresLq7bffpunU4+taRFEccO0+88wzOH78OH29YsUK9PT09HvPR9el5tO+/+K9txgneOutt3TXkmpPFv0TVLx5G+rfwqEya9YsmEwmLF++PKZcPSeNenW/8sorMZ8ZAyHLMqZNm4aKioqY98iGDRuG9DPlrFmzYDab8eSTT8a043/+53/Q1taGa6+9lt5zu9147733Yn7G/8tf/tLv5+7B1mxPT0+Mq5nu7m48/fTTcDgcuPjii2ldQOz7sbe3F88880y/8kRR1P1Z83RgitNZZvz48ZgxYwZuv/12dHV14YknnoDdbkdJSQlNc9ttt+Hxxx+Hoij4zne+g08//RQrV67EhRdeGGO8arVaMXnyZLz00kvweDxITU2F1+sdsh1JNLNnz8YLL7wASZIwefJkbN68GW+++SbsdntMusmTJ+OKK67AxRdfjNTUVNTW1tJj4SrqYr/77ruhKAp4nsfNN9+MmTNnYuHChXj44YfxwQcfIBgMwmQyYceOHXjllVfwq1/9Cl//+tdPuu2M4eH111/Hxx9/DOCEs8BVq1Zhx44d+NGPfkQ3FHosWbIEGzZswIwZM3DHHXfAaDTi6aefRldXFx555BGa7oc//CFWr16Nb3zjG7jttttw8cUX47PPPsOf/vQnrFy5ElOnTu1X9p133onDhw/jJz/5CSRJwo9//GMcOXIEo0ePxte//nVMnToVNpsNb775JmpqamJUgtNpazAYxNixY/Gd73wHP/zhD8HzPJ599lk4HA7s27cvpsyLL74YK1aswJIlSzB+/Hikp6fjq1/9Kn3e3d2N/Px8zJ07F5988gl+/etfY8aMGbj++utpmu9+97tYtGgRbrrpJlx11VX48MMPUVVV1U9lmDZtGniexy9+8Qu0tbXBYrHgq1/9aoyPopHMXXfdhY6ODtx4442YOHEiuru78e677+Kll15CVlYWQqEQTXvxxRfjzTffxOOPP47MzEyMGzcOeXl5Q/5bOFRUv34PP/wwZs+ejWuuuQZbtmzB66+/3m9+Z8+ejfLycoRCIVx66aWor6/Hiy++GKPgDMbDDz+Ma6+9FjNmzMBtt92Gzz77DMuXL8eFF14YVzmObuv999+PsrIyFBQU4Prrr6drNjc3N8Y1yXe/+12sXr0aBQUFmDt3Lnbt2oXf/e53dJOj4na7kZycjJUrVyIxMRGiKCIvL4/aZ2VmZuIXv/gF9uzZA4/Hg5deegkffPABnnnmGXqo4sILL4Tf78f999+Pzz77DKmpqfjDH/5AFfFoLr74Yrz00kv4wQ9+gNzcXNhsNlx33XVDHj9dhvWM3ggjnjsCURT7pdUeZ1bdETz66KPkl7/8JRkzZgyxWCzksssuIx9++GG//L/73e9IdnY2MZvNZNq0aaSqqqqfOwJCCHn33XfJxRdfTMxm86CuCfTar/L555+TUChE0tLSiM1mI4qikI8//rjfkdMlS5aQSy65hCQnJxOr1UomTpxIHnrooZjjvj09PeSuu+4iDoeDcBzX71j3M888Qy6++GJitVpJYmIi8fl8pKSkhBw4cICmcblcg7o9YAwPeu4IBEEg06ZNIytWrIg5Qk2I/tH5999/nyiKQmw2G0lISCBXXnkleffdd/vVFYlEyJ133kmcTicxm81k9OjRZN68eaSlpYUQEuuOIJqSkhICgDz11FOkq6uL/PCHPyRTp04liYmJRBRFMnXqVPLrX/96SP0dalv/9a9/kby8PGI2m8nYsWPJ448/rnss+uDBg+Taa68liYmJBAA9iq2m3bRpE1mwYAFJSUkhNpuNFBUVkUgkElNXb28vue+++0haWhpJSEggiqKQnTt36h75/s1vfkOys7MJz/PMNYGG119/ndx2221k4sSJxGazEbPZTMaPH0/uuusucujQoZi0H3/8Mbn88suJ1WqNcQsw1L+F8f6e6h2R7+3tJWVlZUSWZWK1WskVV1xBtm7d2q/Mzs5O8v/+3/+j6b7yla+QzZs39zviH+99ovLqq6+SSZMmEYvFQiZPnkzWrFmj+/kRj6eeeopMnDiRmEwmMmrUKHL77beTzz//vF+6X/7yl8TpdBKLxUK+8pWvkNraWl13OmvXriWTJ08mRqMxxjXBzJkzyYUXXkhqa2tJIBAggiAQl8tFnnrqqX517dq1i8yaNYtYLBYyatQo8uMf/5hs2LCh31i3t7eTwsJCkpycPCQXDEOBI+QULNYYp82ePXswbtw4PProo7j33nvPdnMYDMYZRnX0WlNTExPKhsFgnOCKK65AS0vLkG0fzxbMxonBYDAYDAZjiLCNE4PBYDAYDMYQYRsnBoPBYDAYjCHCbJwYDAaDwWAwhghTnBgMBoPBYDCGCNs4MRgMBoPBYAwR5gATJ2KmHThwAImJiSyEwZccQgiOHDmCzMzMmGjfIxG27kcGbM3Hwtb9yOBMrnu2ccKJ2DhqTCrGyKChoQGjR48+2804q7B1P7Jga/4EbN2PLM7EumcbJwCJiYkATgzwQKEkGOc/hw8fxpgxY+icj2TYuh8ZsDUfC1v3I4Mzue7Zxgn/iZ6clJTE3kgjBCbRs3U/0mBr/gRs3Y8szsS6P6s/eL/99tu47rrrkJmZCY7j8Nprr8U8J4Rg8eLFkGUZVqsVs2bNwo4dO2LSfPbZZygqKkJSUhKSk5Pxne98Z9DAhQwGg8FgMBinwlndOB09ehRTp07Ff//3f+s+f+SRR/Dkk09i5cqVqK6uhiiKUBQFnZ2dNE1RURH+/e9/Y8OGDfjLX/6Ct99+GwsWLPiiusBgMBgMBmMEcVZ/qrv66qtx9dVX6z4jhOCJJ57AAw88gBtuuAEAUFlZiVGjRuG1117DzTffjG3btmH9+vUxQTOXL1+Oa665Bo899hgyMzO/sL4wGAwGg8H48nPOnk3dvXs3Dh48iFmzZtF7kiQhLy8PmzdvBgBs3rwZycnJMZHGZ82aBYPBgOrq6i+8zQwGg8FgML7cnLPG4QcPHgQAjBo1Kub+qFGj6LODBw8iPT095rnRaERqaipNo0dXVxe6urro68OHDw9XsxkMBoPBYHyJOWcVpzPJww8/DEmS6MV8ejAYDAaDwRgK5+zGKSMjAwBw6NChmPuHDh2izzIyMvDpp5/GPO/p6cFnn31G0+hx//33o62tjV4NDQ266crKyiCKIhYsWICioiLU1tYCAGpra1FUVITKykrdf6PTFRQUIBAIYPz48bBarSgrK9PNHwgEkJmZicrKSlq/mi66vEAggLS0NEyZMgUFBQW6bSooKIh5Fl1edP5AIEDTVVZWQpZlXHrppbr9Uvuhplf7Fq8etd3RdQ5U9vjx42GxWDBlyhSaL7rvlZWVyMrKouOjtlcdh8HGknF6aOfjVO+fTNln6tmp5h1qn06nXWeyX4yTh617tu51IecIAMgf//hH+rqvr49kZGSQxx57jN5ra2sjFouF/P73vyeEEPLRRx8RAKS2tpamqaqqIhzHkcbGxiHX3dbWRgCQtra2mPsJCQkEAOF5nhiNRlJYWEgIIaSwsJAYjUbicrl0/41Ox3EcAUCvhIQE3fzqc5fLRetX00WXF10Wx3G6beI4LuZZdHnR+aPL0LZB2z61H2p6tW/x6lHbra1zoLLVS80X3Xe1fer4RLeX47hBx3KwuR6JnMxYaOfjVO+fTNln6tmp5h1qn06nXWeiX2zNx8LWPVv3p8tZ3TgdOXKEbNmyhWzZsoUAII8//jjZsmUL2bt3LyGEkKVLl5Lk5GSydu1aUldXR2644QYybtw4cuzYMVpGQUEBycnJIdXV1eQf//gHmTBhArnllltOqh16A1xTU0PcbjfhOI4IgkACgQCpqakhFRUVJCMjgwQCAVJRUUHcbjcBQIxGI5kzZw6RZZn4/X6a1m63E6vVSgwGAzEajcTtdhOv10tkWSbz588nsiwTt9tNJEkikiQRr9dLFEUhpaWlxG6397snSRLheZ6IokjbUFhYSEpLS4nL5SKlpaVEURSiKAqpqakhNTU1xOv1ErPZTJxOJ5EkiYiiSKxWK+F5nthsNuL1eonT6SQcx5HRo0cTt9tN06vtzMjIID6fjwQCAeJ2u4nRaCRWq5XY7XZSWlpKCgsLSU1NDSGEkIqKipi2qOW53W7i9/uJz+cjdrud+Hw+WpbBYCAASEpKCvF6vbrj4XK5SEVFBa1DHVuTyUTbWlpaSrxeLzGZTESSJJp+oLkeqZzMWNTU1MTM8aneP5myz9SzU8071D6dTrvORL/Ymo+FrXu27k+Xs7pxeuutt/opIADIvHnzCCEnVKef/vSnZNSoUcRisZD8/HzyySefxJQRiUTILbfcQmw2G0lKSiKhUIgcOXLkpNqhN8BatSghIYFUVFQQs9lMABCTyUT8fr9u+wEQWZaJLMv91JToy2Qy9VOjVBVHEIR+6bX31Dp4nidpaWkEAN3gqZspVTUbqBxtG7T3zGYz4TiOKIpCAoGAbl+NRiNRFIUUFhYSRVH6qUo8z5OEhARiMBgGHBN1A6W97HY7URSFVFRUEEVRiN/vJ3a7vZ96pihKjOqUlpY26FyPVNhYjAzYPMfCxmNkcCbnmSOEEIxwDh8+DEmS0NbWRl3w19bW4oEHHkBjYyN27tyJ7u5ujBkzBnv37h20PEEQqJPOjIwMHDt2DG1tbYPm6e7uRnp6OqZOnYrGxkZs3bq1XzqLxYKurq6YOlwuF9rb2xGJRJCRkYGvfvWrePnll2E2m9HR0RG3bWazGRdccAFEUaT5Fy5ciIceegjHjx/vV7eiKPjwww9jTixyHIe8vDxkZ2cjEongr3/9K/Lz82G326EoCqqqqqAoChYvXoyGhgZwHIfe3t6Ycs1mMwwGAzo7O+F2u9Ha2opIJNKvfo7jMHbsWOzbtw/Ry1bth+q3680330RfXx8IIbDb7WhpaaFp9eZ6pMLGYmTA5jkWNh4jgzM5z+escfjZZvr06Vi/fj3q6+vx9NNPY8yYMQiFQvB6vTCbzUhPT4fZbIbD4YDZbIbT6YTdbkcgEMDTTz8Nv98Pu92OtLQ02Gw23TrS09Ph9/vh8/kgiiLy8vLw5z//GUuWLIHNZoMkSbBareB5HqIoIhAI4P7774fL5cK3vvUt2O122O12lJeX46677kJCQgKuu+46RCIR5ObmIjs7G7Iso7S0FD6fD4IgIC8vDxaLBT6fD++88w7q6uqwfPly2Gw2dHd344UXXkBCQgIkSYLb7YbdbofT6YTZbMb777+PlpYWCIKAtLQ0zJw5EwaDgRriBwIBOBwONDY2oq6uDj/4wQ+we/duTJ48GatXr8bNN9+Mn/70p3C5XJgzZw5tx29+8xtMmzYNsizjW9/6FmRZhtFohNVqjWmH3+9HeXk5gsEgfD4frFYrOI6Dw+FAZmYmlixZgiVLluC//uu/8OCDD8LlcuHxxx//IpcNg8FgML7sDLuGdR4ymKSnNXY2Go30J63on9eiDdP0DMO1V0JCAiGkv+FztOF19E9n0fVrjcDjGaxrjat5ntc1Qte2zeVy9eurtt1a43k9Q2+13YONqdqX6J/ZoDH81o6vNt1QDDKZTP8f2FiMDNg8x8LGY2RwJuf5nHWAeS5RXFyMSCSCSCSCsWPHwmAwoK+vD5IkIS0tDQcPHsTx48fR0dEBWZYxbtw4KIqCpKQkHD16FFarFUeOHAHP8yAn7MpgNBpRUlICACgvL8ePfvQjyLKM2tpaWt/hw4dx5MgRNDU1ITMzE5FIBIWFhdi9eze2b98Or9cLAKiqqqJt6unpQW5uLgghyMzMRDgcRkFBAUKhEJ5++mkkJCSgpaUFra2tmDJlCkRRBAC43W40NDRAFEW0tbWhp6cH4XAYkyZNQjgchslkos/6+vpw6aWXIhgM4k9/+hNSU1NhNBoRDAbx5z//GaIo4uDBg+js7ITVakVdXR18Ph+amprg8XiwaNEiRCIR5Ofno7i4GAAQDoexY8cOGI1GSJKEnp4e9PX1obu7G4cOHYIoirQc9SdFt9uNcDgMQRAwbdo0KIqCgoICtLW1QZIkLFmyJMarPIPBYDAYp82wb8XOQ4ayM9VTX1S1SX2tqjn4P8VGez+eOhVdvlZV0SpdhYWFMQqVVqFR7w+kSmkVpGhVJ/qZVvHS1qV1UzCQiwG99sVT6PSUJ70yopWqaOVKLUNPgWLfNv8DG4uRAZvnWNh4jAyY4nQWUI3DVaPu5uZmGI1G5Obm4t1330VCQgKKi4uxefNm7Ny5E/v27QPP87DZbBg7diw+++wzWtb48ePR0NCA3NxcvPfee/B4PFRpUfF4PDCbzRBFkaomAKgq89FHH+Gtt95CXV0denp6wHEcDh06hMTERHAcB7PZjK6uLhiNRrS2tiI3NxeLFi3CihUrsHfvXiiKgsmTJwMARFFEZWUl0tLS0N7eDgBITExEfn4+AoEAVq5cSe2GrFYr1q5dC0EQYDQaIYoiurq6MGnSJNx0001Yvnw5Ojs7IQgCcnJy0NzcjJycHLS3t6OjowPd3d3U+NtoNGLy5MlYtGgRVq5ciY0bN6KsrAybN29GW1sb8vLy0NzcjMbGRmRlZeHAgQPgeR4WiwVpaWloaWmhfW9pacG6devg9Xphs9kQDofR3t5OjQAnT57cb4wZDAaDwThthn0rdh4yFHcE6pWQkDCgvY2qhETnUW2AVPVGzzGjqrBEq1haB5cDqTjaZ9H2UvGULD3HlvHapa1LT+HR2n3p5YnXX60jTq0tVjy7r2jlKV5dg831SGWwsTgZnzQng165p1rXmWrjF8Hp+r0Zajq25mNh6/7s8mVY90xxioNqZ6SGZGlsbITNZsO9996L7du3x9jTNDc3g+d5AEBrays8Hg+8Xi+2bdsGjuOQkpKCtrY2pKSkQJIklJeXU0ULAAoLC5GYmAhBEFBUVIR9+/ahra0NHMdRG6VAIIBRo0bB4XCAEILdu3ejq6sLKSkpaG1tBcdxIIQgOTmZ2ihlZmZi9uzZcDqdUBQFlZWVWLx4MUKhEPLz89HW1gZZlgGcOOofiURQW1tL7YJqa2uRmJgIs9mMMWPGICEhAfv27QMAVFdXo7GxEcCJ04G9vb3UpUBKSgq1P1JdH2RlZSESiaCyshKPPvoo9u/fD6PRCFmW4XA4IEkSFEVBXV0dPa3Y1dWFxMREBAIBtLe3IzU1FcnJyfRkX29vL4xGI8LhMPLy8gAAR48exb59+1BVVYXKykrceuutX9yi+ZKxbNkyvPzyywCAF1988YyWe6p1nak2fhEM1Pah9ut87v+5Clv3Z5Yvxbof9q3YechgO9NoJUVlIAVooBNrWgVJtecZ6GSe3smyaNUIA6gv0QpZtG2UVomKp0zFsyGKvnie11W89Nqhd2pOT1WLHpfotHqqlrYMvXkY6lyPJNg377PLl+Gb9/kIW/dnly/DumcOMDGwo6zKykrccccd6OjoQGZmJrxeL5YsWQIAeOCBB3DgwAGEw2EcO3YMfX194HkekyZNwte//nVqK0QIoSfjVH9DAHDbbbdhx44dyMvLwzvvvAOO43DppZdSO6h7770XK1aswI4dOyAIAiKRCH70ox/h2muvjVGrHn30UezcuRPf+ta3sG/fPmzdupUqZLfccgv+8pe/wG6349ixY2hsbKRlLFu2jNpP/eAHPwDHcbjzzjupzRFwwraroaEBY8aMgdVqRVNTE1V9jh07huPHj8NiscDpdFIbJKPRCJvNhubmZjquDocDfX19mD17Nl5++WW0t7fDbDZTNQsALfvgwYPUGahqgzV27FgAJ07fdXd30xN+DocDvb29mDBhApYvXw4AuOuuu7B3714sXbq0n+LEnN/9BzYWIwM2z7Gw8RgZnNF5Hvat2HnIQDvTgRQSQvSVJz3/S2ragXwrqXmj82nLH0gtUlWp6FN8Q7H/0apWenZL0fn1AupGK0papelkTt7phXzRC0SsXqraxfw4nTxsLEYGbJ5jYeMxMjiT88w8h8ehtrYWRUVFCAaDVFFRvWi/8sorGD9+PAKBAKqrq2EymagHcbfbDb/fj+zsbJhMJgDA66+/Dp/Ph3Xr1iE5OZnaG7W3t0OSJGRkZIDneZhMJuTk5NA2VFdXQ1EU5OXlQRRFmM1mhEIheDweGI1G1NfXo7KyEnV1deA4Dk1NTeB5HjNmzEBCQgKKiooQiUSQl5dHPZQnJSWhqqoKZWVlKCoqQmVlJRITE6n/pKysLHAch/T0dCQlJdFTbE6nE36/H16vF4cOHUJvby86OjogCAK148rPz0coFEJqaiosFgs97WcymeB0Oqm/J4fDgWAwiKSkJPA8D6vVClEUIUkSZFmGKIrgeR48z8PpdCIjIwOKoqC8vBySJMFoNCI5ORkcx4HneSQkJCA1NRUejwcFBQXw+Xyw2WwQBAFlZWVnawkxGAwG48vIsG/FzkPinarT88QdrQ5FX1q7JqPRGPcEXLR6oqfIaNNqbY20ClG8tugpU2oaPYWI4zjaP20/1TIH8q80kB3UYP3Us2mKbqdWpYunhOnlH2yuRypsLEYGbJ5jYeMxMmCK01lAURQ4HA7q3ygUCsHpdOK6666DyWSCwWCA1WqlykpTUxMcDgfKysoQDochiiKMxhOHFtPT0+F0OqmSU1VVBaPRiLS0NKSkpIDjOACI8ZWkKi719fVQFAXBYBDBYBDFxcUoLy+H3W6HzWZDa2sr3G43RFGEwWCAyWRCKBQCcMJfU19fH6qrq6lHcq/XC0EQEAwG4XA4YDQakZSUBEmS4PV6kZGRAYPBQFUok8kEjuNgNBpRVlaG1tZWGAwGqgZJkgRRFGlQX9UflarQud1upKWlQZZlJCQkwGg0wuv1ory8HF6vN6YOu90Onudp+QCQm5uL/Px8erowGAwiISEBubm5MBqNVLFKTU1FKBRCXl4eJEmial8wGDwLq4fBYDAYX1qGfSt2HjKYHyee54nb7SYcxxFFUUhaWlpc1UUQhH42SYT8RykxGAz0WfT/tVd0GWlpaaSiooIUFhaSiooKoigKsdvt/eyeok/g1dTUEEEQaBpZlonf7yeyLBODwaCr2Oj1K7od0e3lOI6+NplMhOM4EggEaLmyLBOv10sEQSButzumTFmWSWFhIfH5fHH7r14WiyWmrYIgEJ7ndW2hZFkmdrudcBxH+649Wce+bf4HNhYjAzbPsbDxGBkwP05nAdWP04YNG9Db24vdu3fTZ+T/DiKq6pB6Ag044SXc6XSisbERkUgE5eXlAE7Eo1u8eDFaWlpw9OhRAEBfX19MnTzPo7e3N6YOAGhpacHixYvR2NiId955B/v27aPPBUFAeXk5Jk+ejEgkgrq6Ouzfvx/Lli3D+PHjsXXrVphMJjQ1NaGpqQkAkJCQgM7OTlgsFnR1dQEAMjIy0N3d3W8cVP9QZrM55jn5v5h7AHD8+HEAwO7du9HZ2YmEhAS4XC6899579L6KwWCAy+XCyy+/TFUlvfoEQQAAWCwWtLW10Vh8nZ2dcLlcCIVCePjhh2n7BUGg/VPn4ciRI3T8GQwGg8EYDtjGKQ7Tp0/HkiVL0NjYiB07dmD06NFIT0/Hzp070dbWBpvNBq/Xi9tvvx3l5eUIh8NwOp2w2Wx007Rw4UJUVVVh8uTJ9Fj8HXfcAYPBgMsuuwxbt26lDiKNRiPGjBmDY8eOYe/evTCZTCCEoKenB5MmTcK9996LVatWYefOndQYvbOzE7Is49FHH6X1qahuBhYvXoycnBy8/vrrkCQJra2tkGUZ6enpIITg6NGjaGlpwdKlSwEA9913H3iex6FDh2AymWA2m2mYlFWrVqGxsRHbt29Hd3c3TCYTEhISMHbsWGRmZmLs2LF44YUX4Ha7UVBQgA8++ACdnZ247LLL0NXVha1bt+LYsWPYtWsX/amzu7sbRqMRvb294DgOGRkZtF82m426I1DdHIwdOxaEEDz99NO4//778eqrr+KTTz6B0+kEAOzduxeiKOKHP/whc37JYDAYjOFn2DWs85B4kp6egTgQG0YlnqG3+lrPsBk6xuRaZ5hqunjOHfXCmmjr0wb11QYhju6bXjgUbdgT7fN499Vn2r7q/bw22DVQoGGXJsixnguIoc71SISNxciAzXMsbDxGBsw4/CxRXFyM3NxctLa2QhRFJCYmwu12QxAEXHfddXA6ndQ1gMFgQEpKCrxeLywWC4AThs25ubmoqqrCpZdeilAoBLvdTo/d19bWorKyEhs3bkR2djYyMjIQDAYxatQo+Hw++Hw+ZGRkoKOjA6IoorGxkRpT5+bmUgNy1RWCLMs0KLDa/vz8fKSkpMBgMCA1NZUabLe0tIDneeTk5NCQLAComwT1OH90W2traxEIBNDS0kKDCVdWVqKyshKyLMNoNMJutyMQCCAYDMJoNNKroKAARUVF1D0BAJhMJuqewGg0wmq1QpIk+Hw++P1++P1+ahRvMBjgdrtRXl4Ov99PDc5DoRAkSYLZbMbEiRNhMBgwevRoGj6GwWAwGIxhZdi3YuchQ3WAqVV/9EKIaBWQ6Nd6jjCj1SqtAqSmU5UfrZoV/f94TiD1Qr7oOa6M55Qz+rm2LLW/en2M5zhUW4bWKaZW/YouS8/lg15gYL1AzEOZ65EGG4uRAZvnWNh4jAyY4nQWUY/+S5IEv98Pj8cDWZZRXV1NHTlKkkTVH9U5I3DCHUAwGITZbIbVasWaNWsgCAJEUcTcuXNRXFyMUCgEQRCoWlReXo78/HxEIhF4PB44HA5kZGRAEATccMMN9Ki9wXBi6nieR1FREbxeL0wmE3WK6fP5YLFYsGHDBto2SZJo+yVJomqSJEmoq6tDQUEBdYA5depUuFwulJSUYO7cufB4PKiqqoIoivRS84dCIWRkZMDtdqOqqgrjx4+nKp0oitRlQ0dHB8LhMK3b7XYjIyMDWVlZ6O3tRVNTEwRBoA46CwoKMH78eBpYuKenhypf4XAYgiBg9erVyMnJQVpaGgRBQFpaGm2zqrwxGAwGgzFsDPtW7DxksJ2pnroBxHe6qLUlGsgpo9a5ZXR9at7oZ3oOKIfiFBMadUfPWWW0A0yt40ht+XoKkbZerR2WNjyKVlEbKG90X/UCGcezuzrZuR5JsLEYGbB5joWNx8iAKU5ngdraWhQUFCAQCCAcDsPlcuGVV15BR0cHzGYz0tPTYbFYkJOTg1GjRsHtdsNut1O7JI7jMHr0aCQmJiI1NZXaRnEch66uLpSVlcXYN6WmplLVx+Px0BNwXq+XOr6sra2ljinNZjNVkVQHmaodUDAYpCqTqnCpdlcdHR1wOBzweDyIRCLwer3UiWVSUhISExMBnFDLAoEACgoKUFtbi2AwCI7jYLVa6Qm3YDCI3NxcrF69GhzHYf/+/VQJs1qtkGUZSUlJ1KZpxowZyMvLQ0ZGBq1/0qRJEASB5nM4HJBlGUVFRUhNTaVjBoDaLnk8HqSmptJy1fao6ldBQQFtN4PBYDAYw8qwb8XOQwZzgBmtxKivVRsjvZNs2hNyevY90SrJQOFI9E7fRafTKmHRNkfRdlNAfLsiPWUnOm28ALta+6boS1WXtOm1JxW1J/7U/mrHUXu6cTA1Kl7QX/Zt8z+wsRgZsHmOhY3HyIApTmcBRVHo6bZgMIjrrrsOHMeB4ziIoghZliHLMrWnURQFRUVFNChv9Ak5v9+P4uJiGvLEYrEgJSUFra2tSEtLQ1FRERISEsBxHJKTk7F//35YLBaYTCYkJiaisrISBQUFqKuro2FLsrOzkZiYSNUjtT2hUAiJiYkQBAFFRUWYO3cuDW+iBiEOBoMIhUI0pExeXh4NCZOSkkJDpkiShKSkJITDYRrsOD09napZra2tyMnJoYoQAHpqLisrC16vlwZGFgQBoVCInvRTbbpKSkogyzKysrIgyzKCwSCysrLg8XgQDAbh9/tpP6PDxKSmpsLpdNLTeDabDVVVVfB4PHT81ZOCDAaDwWAMG8O+FTsPGSjIr54PJQD9FI3o9FpVZaCTbqoKAx3VRq0nnp2UVpEZiuIS3YbodNHKj17wX61CpG2Htr16AZL1ThVqX2ttleL5uNLWox1PdqpucNhYjAzYPMfCxmNkwBSns0BxcXHMySxFUejpOtXPUHV1NaxWKxYsWIC6ujr09fVh1apV2LRpE3Jzc2P8KlVWViIrKws33ngjXn31VepbKScnB5FIBG63GxaLhfpkig6g29TUBEIIrFYrDZqbnZ2NzMxMACdsfPLz86EoCrVbSk1NRXV1NZKTk+FwOLBgwQJkZWWhsrKS9k9VdMLhcIyvJ0mS+gX/jVaI7HY7PVWXnZ2NvLw8uN1uGoJGDYys2l2p7V+7di2mTJmCcDiM3NxcRCIRVFZWIhKJ0HFSTxkmJiaitrYWiqIgNTWVBvFVlTOn04nW1lYkJycjJycHJpMJkiQhFAohHA7D4XAwxYnBYDAYw8+wb8XOQ4ayMx1IgdI7FRbvxFm0YgL095MU78SbesU7jaZVuuKd9NOeOotXj3ppFa3osdCzsVLzxLOL0papV7bWLiz6tdZnkzqGat+YjdPQiTcWNTU1pLCwkNTU1AzpvpahphuuvKdT3+mUezL1nsqYDle/2JqP5Uys+9Odq5PNf6bW/FDKZuv+RKDWc5aenh7ywAMPkKysLCIIAsnOzibl5eWkr6+Ppunr6yM//elPSUZGBhEEgeTn55Pt27efVD3xBriiooK4XC5SWlpK/H4/kSSJCIJALBYLmT9/PvF6vUQQBDJ//nzi9/uJ0WgkAIjRaCSSJJFAIEAnv7S0lJjNZmI2m4nJZCJOp5NIkkQkSSI+n48oikJKS0uJLMvE7XYTSZKI1WolPM8Tk8lEABCLxULTBwIB4na7idlsJunp6cRisZA5c+YQWZaJ1+slfr+feL1eIooiMZvN5KKLLiI8z5M5c+YQRVGIoiikpqaG1NTU0L5JkkTcbjex2+30X5/PR/x+P21fRkYGrd/v95NAIEC8Xm9MXwKBAFEUhcyfP58YjUZiMBiIwWAgPM8Tp9MZU77aV3W8KioqiN/vJ3a7nfj9flrn6NGj6bjJskzmz59P7HY7sVqtxGAwEJPJRObPn08URaHt1XvjsQ+R/zBYqCG9n5jj/QR6KumGK+/p1Hc65Z5MvacypsPVL7bmYzkT6/505+pk85+pNT+Ustm6P8c3Tg899BCx2+3kL3/5C9m9ezd55ZVXiM1mI7/61a9omqVLlxJJkshrr71GPvzwQ3L99deTcePGkWPHjg25nngDnJaWNqAS4/f7Yz6ktQpLtLqjKAq9b7FYiMFgiCkr+l/tpadoRefXe56WlkYqKiqILMuE4zhisVj6pZEkiciyTHw+X4wSxPM8SUhIoBseAESW5ZjxEASBKjvqPbPZTDdhaj+1fdJrR/SVkJBA26OWqU3j8/kIIf3tztS2Kopy0nM9EmGK0+mVy755n58wxen0ymbr/hzfOF177bXktttui7n3ta99jRQVFRFCTqhNGRkZ5NFHH6XPW1tbicViIb///e+HXE+8Abbb7QN+yGt/mqqoqKAf3gaDgVRUVNCyojdOkiTFDXgbvSGK3qCoG46T2VxFb+TUzYxeHWrZsixTlU1ViKLTacdjoJ/41CsjI6NfPr3Nk91up5ukwTZXFouFEHLiDabXb7ZxGhpsLEYGbJ5jYeMxMhixxuGXXnop/vrXv2L79u0AgA8//BD/+Mc/cPXVVwMAdu/ejYMHD2LWrFk0jyRJyMvLw+bNm+OW29XVhcOHD8dcWmprazFhwgRqoG2xWGAwGHDRRRfBbDZDFEUaiDc1NRXhcBi7d+9GSkoK0tLScP311+P2229HWVkZAKCwsJAal0+aNAklJSU0TInZbAYAWCwWTJ48GU6nEwaDAU6nE+PHj0dqaiomTJiAQCBAXQcYDAaIogjghLPJn/70p7Db7RAEAUajET6fD+Xl5VAUBYFAAJMmTaJOOC+77DJqiC4IAq6++mrY7XZ0d3cDAFavXo2bb74ZM2bMAABwHAdJkmg+p9MJWZZxww03QBAEGmJGdZCpOtu02Wz4xS9+gccffzzGoHzChAlwOBwAToSMmTNnDmw2G+bNmweXy4X7778fiqJQFwlqEOI5c+bQIL6VlZV44IEHMGnSJKSlpcHtdkOSJNjtdhQWFqK2thZFRUXMCSaDwWAwhpdh34oNI729veS+++4jHMcRo9FIOI4jP//5z+nzd955hwAgBw4ciMn3jW98g8ydOzduuQ8++KCukhHPHYGe+wC94//RRsrRDjKjyxvIGHogB48DuQRQ64z+yS/692Ft3VrnnfEcbuq5HojOpx0bvUvPaF0b2kXrgkBbf/QY6jkX1TM0j/c7Ofu2+R/YWIwM2DzHwsZjZDBiFaeXX34ZL774IlatWoX3338fFRUVeOyxx1BRUXFa5d5///1oa2ujV0NDQ7800e4IFEWhwXUTExPh8XioCqI6yszOzobJZEJaWhrKy8tRVFQEg8FAHUKuWbMGycnJCIVCMQ4zRVGE2WxGdnY2ZFlGbm4ujEYjeJ6PUbUyMjKQk5NDj/wLgoALLrgAwAml59ChQzCbzeA4DtnZ2bT8srIybNy4Ebm5uQiFQpAkiSpIaoDfxMREqmIZjUbqcFOWZers0ufzoaSkBGlpaTCZTJg0aRLq6upQVVVFHVGq7VUdX/p8Phoixel0UrcBSUlJuO6662hw48TERKSlpSExMRGBQAA+nw9paWlISUmBxWJBdnY27YfX64XX60VLSwt1BNrT04Pe3l4cOnQIubm51C2D6uLgfOTtt9/Gddddh8zMTHAch9deey3m+be//W3qkFW9CgoKzk5jGYxhgq17xnnBsG/FhpHRo0eTp556Kubez372M3LBBRcQQgjZtWsXAUC2bNkSk+byyy8nd99995DrGUqQ33ihTlRlI1pt0uaBRoFRnw+m/kCj2EQrQNrQLNFXQkKCbrui1SFtmJfosvXUHLXdap3a9PHGTU/piu6XnnPL6Eub16UJ86JVxb4sDjD/93//l/zkJz8ha9asIQDIH//4x5jn8+bNIwUFBaSpqYlen3322UnVcb6MBeP0OJ/mma17xnBxJufZeAb2YsNGR0cHDf6qwvM8+vr6AADjxo1DRkYG/vrXv2LatGkAgMOHD6O6uhq33377addfWVmJH/zgB+js7ERSUhLGjh0Lm80GQggmTpyI4uJifPTRR6iqqkJnZydsNhva29tRWVmJ4uJiRCIRbN26FY2NjTAYDEhISEBdXR0CgQAAIDc3F0eOHEEkEsHs2bPxxhtvIBQKYf369dixYwcyMzMRiURQWFiISCSCnTt3Ys+ePQCAyZMn495778WKFSuwbds2ACdsilpaWpCSkoJ169ZBFEXYbDYaBLe6uhoGgwGEEASDQRw8eBA7duyAx+OBoihYuXIlxo0bh0WLFtFyW1tbkZGRgVdffZWGmunu7sbChQvxwgsvYPfu3cjJyaHhZqqqqui/qpqWk5MDAMjJycHrr78Oj8cDURRRWVmJ7OxsJCQkYN++fRAEAcePH0dKSgo+//xziKKIUCiEzZs3Y+vWrejs7EROTg4OHjyIlpYWHD9+HLm5ufjHP/5B10ROTs6XwgHm1VdfTW354mGxWJCRkfEFtYjBOPOwdc84Lxj2rdgwMm/ePOJ0Oqk7gjVr1pC0tDRSUlJC0yxdupQkJyeTtWvXkrq6OnLDDTcMmzuCgdSfaOVIfa7nZDKe8hRPudKzTYpWZ4DBVR5tXXrXYMpMdDlqvWo4Fj31SS/Eip6apuaPfqYd5+hwMlo7Km1Z2iDB2rxDnetzGcT55i1JEnE4HMTj8ZBFixaRlpaWkyr3fBwLxslzvs4zW/eM02HE2jgtX74cX//613HHHXdg0qRJuPfee7Fw4UL87Gc/o2lKSkpw1113YcGCBcjNzUV7ezvWr18PQRBOu/5QKASTyQSr1QpJkhAOh6mK4vF4UFlZiY0bN8LpdMJsNiM1NRUmkwmtra0oKytDIBBAVVUVvF4vfD4fPaEniiINIaLaO6k2OR0dHTAajViwYEE/O6vU1FRYLBZwHIeenh4UFBSgtrYWtbW1KCgoQCAQoKoScOKbmRoiRg2JIggCVcY8Hg/y8/Np6BOfz0dDyKh9NZlMdCzVUCrFxcWora3tF+g4FArB6XQiFAph0qRJ6Orqgs1mgyzLcDgc1Earvr6eBjYuKSlBKBSC2WymgY1TUlKQmpqK7OxsrFu3DmvWrAHP85AkCSUlJXScenp60NHRAY7jkJiYCLPZDFmWaciZcDj8pT1VV1BQgMrKSvz1r3/FL37xC2zatAlXX301ent74+YZymlSAEM6kRgvzcneP9VnZ6KNJ5Pmi+zP6fTjywZb92dv3Q/3e2K4x+YLZdi3Yuchg3mSjbb50bO10Qa6Ve9Bo+5og+Fq7Z0KCwupcsLzfL+26J2001Oj9NJEn1yL/n90H9U8PM/HLU+vzdrxilaU1LK0dlTafHp907NfUtPrtU3NFz1fX4ZTddD55q1Ftfd7880346YZymlSQk7PU/Jwewo+lfLOZN6z0Z/T6Qch5+eaJ4St+3Nt3Q/3e2K4x0bLiHWA+UURb4BLS0tJQkICmTNnDsnIyCBut5uIokhDrvj9fpKWlkbDhzidTmI0GonNZiNer5e43W5iNBqJ0WgkM2fOJBaLhbjdbuL3+4nP5yMZGRk0dIgkScTr9RKbzUYAkIsuuogQ8h8vqhUVFURRlJjwJoFAgMyfP5+YTCYiiiKtXw0LA4DYbDZSWlpKvF4vMZvNxOv10tAuakgTNayM1+ulbfT5fEQURWI0GkliYiIBQNLT0/vVHx1SxmQyEUmSaFmCINCxCwQCZObMmQQASUlJoX13uVxkzpw5xGKx0DA0oigSu91O5s+fTyRJIjzP0zFVvbU7HA66UbJYLITneWKxWIjZbCZz5swhdrud2O32GCekA831ucxQPkAIOeHpfuXKlXGfd3Z2kra2Nno1NDTojsXpeEoebk/BZ8Kb83B4gv4i+3O6ntzPxzVPCFv359q6H+73xHCPjRa2cTrDDGbjpNrVaFWkeGqU3gk5VVFRVRP1mZ7na7UMQoau7ESrXNF+pNTXWh9P0UpTvAC+0QqUVkWL7gshRHdsouuJbmu0zZS2/IHaGe9k42A2UkOZ63OZoXyANDQ0EI7jyNq1a4dc7vk4FoyT53ydZ7buGafDiD1Vd7YJhUJ45JFHEAwGUV1djYSEBOo/KCcnB83NzcjJyUFXVxccDgc+++wzHDt2DD09PUhOTkZOTg72798Pg8GAa6+9FtXV1ZBlGZWVlTAajTAYDEhNTUVHRwc6OzthNpvR29uLnp4eFBUVURuqSZMmIRwOIxAIQJIkBAIBOJ1OeDweyLKMcDgMp9MJi8WChoYG9PT0gOM48DwPAOjp6UFqaioyMzORmZlJfRtFIhE0NjbC4XDA4/GgoKAAbW1tyM/PRyAQwLJly9DR0YH09HT09fVh9uzZWLNmDTo7O2E0GqnfJUmSEAwGsXbtWmp/pZ5qmzx5Mnbv3o1169ahp6cHBoMBEyZMwI4dO8BxHCwWC66++mpqm9XV1QUAyMrKQjgcBgDk5+ejsLAQK1asoKcAFy1ahKqqKng8Hqxbtw4mk4m2c+HChdRz/Pnqx6m9vR07d+6kr3fv3o0PPvgAqampSE1NRVlZGW666SZkZGRg165dKCkpwfjx48/rk4QMBlv3jPOCYd+KnYecjI3TULxvA/0VIG15eunUS2uXpFVi9NQuPT9I0WqWnvqi2hbp2QVF2x1p1R+959o+Rdc3WH/V8rRptKqRnvoWXfZgv3kPNNfnGm+99ZauEjlv3jzS0dFBgsEgcTgcxGQyEZfLRebPn08OHjx4UnWcL2PBOD3Op3lm654xXDDF6Szh8XhgNpuRlZWFxsZGcByHYDCIo0ePQhRFvPDCC5BlGePHj6eKyLZt29DT0wNBEPC1r30Nf/zjH9Ha2oopU6ZAFEXqu0kURXR2dtITez09PRgzZgwcDgeOHj2KjRs3wu/3o7m5GUVFRaivr0d9fT26u7thNBpjVJi9e/ciGAxSNai3txd9fX245ppr8Pe//x2dnZ3gOA5VVVWorKzErbfeitraWtTV1YHneZhMJrS0tFBfVZFIBIFAAElJSThy5Ag4jkN1dTUcDkeMN+5IJIK2tjZIkoTCwkI8+uij+Pjjj2GxWDBlyhR4PB5kZWUhKyuL+mmaNm0azGYzNm3aBJPJBIvFgnXr1mHs2LHwer3YuXMnuru7MXHiRGzduhVWqxUej4f6idq9ezc2btyIsrIybN++HaFQCCtXroTD4UA4HEZBQQHGjh2LF198ESUlJXjwwQfP8io6Na644goQQuI+r6qq+gJbw2B8MbB1zzgvGPat2HlIvJ2pLMv9vvmYzWYiyzLx+Xwxyo5q/O3z+UggECAcxxFFUfqVEc+mCQBJS0sjNTU1/fIEAoF+qo3P5yMmk4mWJwhCP8XJZrMRu93eTwmaP3++bt+iL0mS+t3TU61qamqI3+/vNyYul6tfHS6Xi9TU1BCDwaBbp14edVw4jiM+n4/mFQSBGAwGIggCcbvdxGQy9ctnsViGPNcjETYWIwM2z7Gw8RgZjFg/Tmcbl8sV89pgMKC7uxtNTU3Yvn07vd/b24tNmzahq6sL9fX12L17d9wyov2NmM1m+P1+mM1mAEBLSwuWLVvWL8/u3buxf/9+mEwmAIDdbsf27dtx/PhxWl5nZydGjRoFv98PjuMAnLAXiEQiMWURQvDss8+iqalpwL53dHTQ/6vlZWRk9LMZWrZsGd57772YMREEAfv374fL5aJ2VjzPo7y8HMuWLaNevqPLNpvN/fJEtxkAtm/fTvOOHz8egiCgs7MTu3btwvHjx/v1YTh8eTEYDAaDEQ3bOA3A7bffDlEUwXEc3G43LrvsMgAnNgG33norCgsLMX/+fCQkJGDmzJmwWCxwu90QRREWiwVWqxXbtm0Dz/Ow2WxwOp0QBAEzZ85EQkICrrnmGjQ1NeGaa66BxWKBz+dDcXExli9fDr/fTx1QiqKIWbNm4be//S0URcH06dNx6623UuecVqsVZrMZixYtwvLly5GdnQ2O4+B0OuH3++F2u2E2m2Gz2QAAU6dOhd1uhyiKkCQJbrcbsixj5syZ4DgOVqsVF1xwAfx+PxRFwYMPPgiXy4WlS5di+vTpAICysjKIooiOjg7Y7XZIkoQJEyZAURQ8/fTTuOSSS1BfX4++vj5wHIfrrruOGnPb7XYIggCTyUQDEv/mN7/BrFmz0N7eTp85HA6YzWYIgoDU1FSMHj2aBg92Op0oKSmBKIp0vqxWKxwOB4ATG7K5c+d+wSuGwWAwGF96hl3DOg8ZyDgcUcbK0T+zDeQETE0TnT7a2Dmegbm2TD3DZ62BdHR90Y42BzPOjmdsPpCLguj2RTu41MujV6fWrYBav15f9C49I3U9dwzR4zHUuR6JnOxYDNV/yrnIUNs+HD6ezgYDtYmt+VjYuj+1dOfiOJytdc+Mw+NQW1uLSCQCp9OJAwcOgOM4zJgxA3//+98xbtw4KIoSE9i2uLgY06dPR3FxMerq6rB9+3Y4HA60t7fDZDLhxhtvxJo1awCcCER76NAhpKSkQJIk5OXl4Y033oAoirTMVatWwWg0wmw244ILLqA/kan/qobXwWAQdrsdPT09kGU5JiSMmra8vJwGK+Y4DoQQagiemZmJQCCA5557jhqYHzp0CDzPw+PxAADtY1VVFRYsWIA///nPSElJQV9fH771rW/h6NGjMQF+i4qKkJWVhb179wIATCYTioqKcPToUXg8HqxcuRKiKKK1tRWZmZmoq6tDcnIyjh07BuBEqBg1NIzalt7eXmRkZKCqqgqZmZnIzc2lYWE6OjrA8zxyc3Px7rvv0kDGoVDoC10zX3aWLVuGl19+GQDw4osvnuXWnBxDbftQ0p2L43AutunLwvk8tmzdnyGGfSt2HqK3M9VzHRCtYMRzIKk+0ztKHy/0iNbRpp7DSi16eaJVLK3aorZXL+xJPDcJeiFOBlPd1LKinVvGc7AZz41DtIoVrU5F163nfmEwBS/eXI9U2DfvU0t3Lo4DU5yGDlv3p5buXByHs7XuOUIGOPs5Qjh8+DAkSUJbWxuSkpIAnFCcHnjgAbS1taG5uRkNDQ3weDx47rnnMH36dFRWVmLx4sUIBoN44403EAwG8ec//xkOhwMdHR1oaGhAeno6enp6sGjRIqxfvx719fVU9ent7UV2djb+8Ic/YN26ddTR5pYtWxAKhbB+/Xps27YNRqMRjz/+OADgvvvuo2rX8uXL0dPTA5vNhubmZkiShCNHjmDy5Mn44IMPIMsyxowZAwDUXcCqVavQ2NiI3bt3o7OzE4IgYMqUKdSZpKIoWLFiBT744AN0dXXBYrHA6XTi4MGDOHbsGAwGA2w2G9ra2iAIAqZOnYqCggKqIHV0dGDhwoXYvn07RFHEiy++SJUmVZHSu//oo4/io48+ovZQhBDwPI+srCyq2kUiEcyePZuOMSEETU1NSE5OxsGDB3H8+HEEAgHU1NTQcSwvL8ett9466FyPVNhYjAzYPMfCxmNkcEbnedi3YuchJ+MAM55yorWvAfRDiURfeo4xtQ4mtc4woVFW4tUZfWltg/QcUEajpwLpXQMF4tWirVur3sWrI56qpTc2WvWNKU4Dw8ZiZMDmORY2HiMD5o7gLFFcXIy5c+ciGAzCbDYjOzsbkUgEtbW1UBQFDocDRqMRDocDRUVFkCSJvjaZTNS5pCzLyMvLg9frhSiK9JSdLMsoKyujYVUSExNp+JO6ujqYTCZ4vV4UFxejvLycnl5LSUmByWSip/0kSYIgCDCbzbjuuuuQkZEBp9MJs9mMhIQEEELw+uuv07Amqm2VGr5F62JArctqtcJgMMBgMIDneVitVlgsFnpiLxgMoqioKMZ9QE9PD2pra+nr2tpaFBQUIBwOIz8/H0VFRTCZTNi/fz/Gjx+PQCCAuro6eoJQPflnsVho22pra1FUVIQFCxZAFEWIogiv1wuTyYT6+noEg0EkJCSgqKgI+fn5kGU5xlEng8FgMBjDxrBvxc5DBtuZahWNeCFJotNBR/EhJL5SpT11Bx0FJ7rO6OdDOUmnvYYSnmSgMvTsn/TK1rZtsJNz2vGKHrNoBU87RnpBhU9lrkcSbCxGBmyeY2HjMTJgp+rOErW1tVi2bBlCoRCefvppGuR37dq1MBqN8Hq96OjoQGNjIzweDxRFoXZPL7/8MvVJpJ4GmzJlCjo6OmA0GhEMBrFv3z7s3LkTjY2NKCoqwnvvvYcdO3YgGAyivr6ehlLJyspCKBRCOBymwXoJIYhEIgiFQti8eTPy8vIgSRIURUEgEMChQ4fAcRwEQcCxY8eQnp6O9PR0ahcUHbZEPcXX2NiISCRCg+Sqp/rUUDAA0NzcjMbGRoRCIRq2xWAwoK+vj9olRSIRLFiwAJWVlUhLS6NtDofDdEzUwMhdXV3o6ekBABiNRqSlpeHAgQNYtWoVqqursXjxYlRXV6Ovrw8WiwXHjh2j9lTACWVKVfk8Hg/Wr18PSZIQDodRW1tL/U4xGAwGgzEsDPtW7DxkMBsnrcKEKKVDVT7iKUN6dkBq+ni2OtFlRatYWlVJ20b1tbaueDZM8Xwraf08xatPW1e04qMNMKznvynePe04afujd2nLi3cakX3b/A9sLEYGbJ5jYeMxMmA2TmeB2tpahMNhSJKEuro6bNq0CQDgcDggSRJsNhva29uRk5ODhIQEBINBFBQUwOfzYd26dTSMiiiK1EZKFEVqk9Ta2opNmzaB53mIoohwOExtdaLLam1tjbFrUm2eysrKIAgCNmzYAKPRCFEUUVBQgOrqavA8T+2S3G43qqurqZfx5ORkquykpKTAYDCgo6MDJpMJTqcTsiyjpKSEKlipqakQRRHJyckQBAEdHR00oK6iKPB6veB5ngYCVhQF+fn5SE1NBQCkp6fD7/cjMTERSUlJ1FN5amoqjEYjkpKSqN1SUlISVbkAYPTo0QiFQpAkCTzPQxAEml8QBHAch/T0dNjtdni9XsiyDK/XS9utKMrZWTwMBoPB+PIy7Fux85B4fpz0TsIlJCQQQgZXbaBRTeKdkINGMYmnAEWXRQjpp2LpndzTU3Ciy9G7p+2/VjHjeX5AmyXtvWhlLbpN8cYquu3RCla8E4fRYxbvBORgcz1SYWMxMmDzHAsbj5EBs3E6CxQXFyMSiWDnzp1oaGhAQkICDh8+DFmWUVtbi/LycixevJjaGLW1tcFoNCIcDlM/RABACEFDQwM99aYqS6r9T3t7OwBg7NixVHXZt28fEhIS0NPTg+7ubqSkpECWZTQ0NCAxMRFlZWU02G1KSgp4nkdGRgaOHTuGPXv2ADgRkLevrw85OTnYv38/ent7qT1QT08P9u7dC57n0dfXB6vVio6ODvT09KCyshKrVq1CW1sbVZ3Gjh2L//mf/0FfXx9SU1OpaqaeWtuwYQOam5uRnp5O7aW8Xi8OHjwIo9FIvX0fOnQIgiDAaDQiNTUVgiCgubkZoiji888/px7Dx44dC5vNhkgkgkAgAKfTiZycHDQ3N1Nv5iaTCWazGYmJicjPz6fez9X5UOeQwWAwGIxhZdi3Yuch8XamFRUVur6YzGYzCQQCpKKighQWFhJFUYjRaCSCIAzJFkeNexetqKSlpdHnevlMJpPu/YSEBKIoCjEYDDF5JUkiHMcRg8FA7ymKQux2+4DtU9uhliHLMgkEAjFpFEUhhYWFpKKigiiKQus1GAx0LAKBwKDjEd02bfkul4vwPE+VJLUsWZaJLMsx46mmNxgMxG63E1mWSUVFxUnN9UiEjcXIgM1zLGw8RgbnpOLU0tKCPXv2gOM4ZGVlwW63n2pR5yyLFy9Gb28veJ5HSUkJVq5ciYMHD6K7uxubN2/GgQMH0NjYiPz8fMydOxd1dXXYunVr3PLsdjvMZjMOHTqEMWPGxCgiVVVVAE4oVACoMqRy/Phx3TI7OzsBgNofqXR0dIAQQstT5ycSicTkV0/EqZAoR/JtbW1oa2sDIQR+vx87duygis/LL7+Md955B/v27aN51HLmzp2LjRs30rZp61DRu2cwnDC7279/P0aPHo3y8nJUVVXRsXW5XACApqYmACeUNTW9IAi0f4sXL+7nNZzBYDAYjNPlpI3D//3vf+Pyyy/HqFGjkJeXh0suuQTp6en46le/ik8++eRMtPGsUV5eDpfLhWeffRbXXnstpk6dSo2P7XY7QqEQcnNzUVtbi+rqajQ0NCAtLQ1z5syByWSCKIrw+Xzw+Xyw2+2QZRkulwt5eXmYOHEiPvroIzzwwAOorq5GW1sbRFFEeno6DAYDvvKVr8But9OfttxuN3w+H4zGE3tdi8WC0tJSXHXVVQCAYDAIg8GA5ORkmM1mjB07Fn6/H06nEwaDAV/72tdQWFhIDa3V8CmyLMNoNMJkMsFgMMDr9cJut0MURVgsFhgMBlx33XVYvnw5FEXBk08+icLCQjgcDiQmJlLHnqpDzcbGRhp812g0wmazYdy4cRBFERzHAQA16I523slxHDiOw/XXX4+2tjakp6cjFArRAMqBQAA8z8Pn82H58uXw+XwQBAGzZ8+m6UtKSuD1eulPeNGOOBkMBoPBGBZORp5qamoidrudTJw4kTzxxBNk/fr15PXXXye//OUvycSJE4nD4SCHDh0adlnsTDMUSU97DF/9fzwja62LAK1xdDwjcNUQWzXCVvPohV2Jblc8p5vRDjbjGbxHX9H1RvdHzzWDXhDjgcrWjs9ALgqix0nbD0Liu2nQa9vJzvVIgY3FyIDNcyxsPEYG54w7gmXLlsHlcmHLli34/ve/D0VRUFBQgB/84Ad4//33MWbMGCxbtuxkijxnUUOFFBQU0BArkiShqqoKHo8Hc+fOhaIoSExMhNFopIbXPp8PoVAITqcTiqJQtwapqanw+Xzw+/0wGo0wGAwwGo3Iy8ujx+uLioogyzIMBgMyMjLoM4vFAo/Hg5ycHNo+Vc1at24dkpOTY1wepKeng+M4dHR00PKCwSAikUg/hUgNzZKcnEzrVVUnp9MJo9GIxsZGiKJI+xwOh5GUlISkpCTqaNLj8cBsNsPpdMJut9O+qpfb7YbZbIbP50N5eTlyc3PR2tqKhIQEWK1WGI1GZGdn0/A0wWAwZhxLSkpoWJWCggIkJiZCkiRwHIekpCT4/X6IoohXXnkFgiDEtI3BYDAYjGHjZHZZOTk55KWXXor7/Pe//z3Jyck57d3cF81A7gj0jt1HhxvRKiZaRWqwkCN64Umiy4p2iqnnSDO6TWo9qkF7tOsAPRUs3jH+6Htq+dEKl16fte4ZtGqP1lGnnlKnzasXPiWem4RoVUrbtsHmeqTCxmJkwOY5FjYeI4NzRnEKh8O46KKL4j6fPn06wuHwyRQ5KI2NjfjmN79Jg876fL4YFYEQgsWLF0OWZVitVsyaNQs7duw47XoVRcGoUaPg9/uhKAoikQh1shgKhVBUVARFURAMBuH3++Hz+ZCRkQFFUaj6IooizZeamkptf1QD6PT0dNTV1cFms0EQBDQ2NoLnefA8j+zsbITDYRiNRgiCgJycHGojZLVaqX2QKIqw2WxoaWlBQkICDSKs2gypoWGCwSCMRiPq6+tRWVmJcDgMh8OBUChEA+N6vV5kZGTQe4mJiRAEAQaDAUVFRaitraWqlaogqe1MTEykzjNzc3OxceNGVFZWxoyn0+mEx+NBUVFRjGNLi8UCk8mElJQUOByOGKVOfa2ijq3X60VJSQkyMjLg8/kQDochyzLMZjPcbjf8fj+CwSBzScBgMBiM4eWkdlkGw4A2TAcPHiQ8z5/uZo7y2WefEZfLRb797W+T6upqEg6HSVVVFdm5cydNs3TpUiJJEnnttdfIhx9+SK6//noybtw4cuzYsSHXE09xihc4Np4SorV70jrH1HMmCY1Cgij1JV7YFq1jSGBwmyJtUFytChbPySSgr6QNFApGL3SMXh3xHIEOZKsUr+yB7JoGm+uRChuLkQGb51jYeIwMzil3BEeOHIEgCLrPDh8+HHOc/XT5xS9+gTFjxuC5556j98aNG0f/TwjBE088gQceeAA33HADAKCyshKjRo3Ca6+9hptvvvmU61aVimjFQlEUFBUVoaOjA319fdi0aRMCgQDa29uxe/duEEKwevVqHD9+HBzHITc3F3v27EFOTg4OHjyI0aNHo7Ozkwb6DYVCqK+vx/vvv4/u7m4YjUYcP36c2iS99dZbaG9vh9VqhSRJ6O7uhiAIqK6uRktLC0RRRGJiIrxeLxoaGgAAaWlpaGlpQVdXF7q7u6ktFQAamiUnJwdNTU3UIafP54PZbKY2VIqiYPLkyaivr8cnn3yCCy64gDq2zM/PR3FxMTweD5YuXYqUlBRYrVZkZmairq4OmZmZkGUZ+/btQ1ZWFgoKCgAAhYWFAE4oRs899xw8Hg/q6uroiT6z2Yy0tDS0traiuroara2tyM7ORkdHB1WpFEWBLMtobW3FoUOHYDabMXHiRNx7770Ih8PYsWMH6urqEAgEIEkSlixZwoL8MhgMBmN4OZldlupQMd6lPh8uJk2aRO655x7y9a9/nTgcDjJt2jTyzDPP0Oe7du0iAMiWLVti8l1++eXk7rvvHnI9Q92Z6gWwHehSFafoE2GIUn1U4p3MG0odA9lVxUsf3Y7o11obo2h1R091054UjC4T6B+eJXoMBzpRpx0/bVgVrS2TXp/ZqbrBGepY1NTUkMLCQlJTU3NaaU61jlN9NtDzU813Ku0fjnadzviyNR/LubLuT2cNngvr/lT6/mVZ9ye1cfrb3/42pGu4sFgsxGKxkPvvv5+8//775OmnnyaCIJDnn3+eEELIO++8QwCQAwcOxOT7xje+QebOnRu33M7OTtLW1kavhoYG3QGuqKggLpeLVFRUkJqaGuL1eokgCOSiiy4iHMcRq9VKfD4f8Xq9RBTFGO/dRqORzJ8/nwQCASKKIjGZTMTpdBJJkogkScTpdBKj0UgkSSLz588ndrudGI1GApzwqD1nzhwiSRLheZ4YDAYiiiLx+XzE7/cTr9dL7HY78fl8RFEUUlFRQfx+P7Hb7cTv91OP3m63m+a3Wq1EkiQSCARIaWkpycjIIE6nk5jNZuJ0OklaWhpxu920jJqaGlJRUUHb4HQ6id1up2mi88qyTObPn0/H4IILLiAGg4E4nU7idruJIAiktLSUEEJIaWkpSUhIIHPmzKFt4HmemEwmMn/+fNpuQRDoa7/fTwKBAPH7/URRFDJ//nzaL57nic/nixkDp9MZU2c07EPkP5zsF4aBfgYdSppTreNUnw30/FTznUr7h6NdpzO+bM3Hcq6s+9NZg+fCuj+Vvn9Z1v05HXLFZDKRQCAQc++uu+4ifr+fEHLqG6cHH3xQV43RDrBWcYlWQgbzYxQvoK9e4F1tINzoOrTKih569kjR7Y+nwujZPWnTDXSST6sK6Y2Pml9V2LT2X3oqnJ59mdb+SptX7360qqfCPkT+w7nyzXuw/Of6N+/hTH++ffM+HzlX1j1TnE6/vQNxJtc9R8jQjZIOHz48pHRJSUlDLXJAXC4XrrrqKvz2t7+l91asWIElS5ZQD9VutxtbtmzBtGnTaJqZM2di2rRp+NWvfqVbbldXF7q6uujrw4cPY8yYMWhra6Ntr62txc0334xwOAye50EIgdFoBCEE8+bNQ319PXbs2IEJEyagoKAAK1euhCiKaGlpAQBMnjwZixYtwooVK7Bt2zYaPkX1pn3gwAEQQmAwGDBu3DhYrVY0NDSgq6sLfX19mDdvHt577z189NFHtLybbrqJBrLdvn07PB4PVq5cCYfDAQA4cOAAMjMzQQhBU1MTkpOTcfDgQXR1dWHUqFHo7OzEhAkTcPvtt6OqqgodHR1Yu3YtOI7DuHHjkJCQgHA4jO7ubkycOBHPPvssHnroIbz22mvgOA7Z2dmwWq1oamqCIAg4dOgQCCGYPHky7r33Xjz66KPYuXMnvvWtb2Hfvn0AgEAggKeeegrHjx/H5MmToSgKfT127FgQQrB7924cP34cP/7xjzFu3Djcd999GDduHBYtWoRVq1bh8OHD+PTTT9HQ0IAxY8bA4XBAkiQEAgEsX74cXV1dsFgsuOuuu7B9+3aIoogXX3wRJSUlePDBB2Pm/vDhw5AkKWauRypsLEYGbJ5jYeMxMjij83xSu6wv2MbplltuITNmzIi5d88991AVqq+vj2RkZJDHHnuMPm9rayMWi4X8/ve/H3I98U7VYRCbIq2qoufFWs+eZ7BLT62Kfh1PrdE7ZTeQnyatmqTNq22D9iSbel/rxVyrjA3WD72TdtF2VQMpZ9Ht0FOshjLXIxU2FiMDNs+xsPEYGZwzp+reeuutk0l+2hQXF+PSSy/Fz3/+c8ydOxf//Oc/8cwzz+CZZ54BAHAch3vuuQdLlizBhAkTMG7cOPz0pz9FZmYm5syZc9p1V1dXY9euXeB5Hn19fXA4HOB5nvoV2rhxI8aNGwdFUagStH79euzduxcejwebN29Gfn4+VZOSk5Nx9OhROBwOevJNPVFmNBoxduxYGtMtHA5TlYrjOIiiiFAohKVLl0KWZaSnp+PTTz/F3r176bPt27dDURQ8+uij2L59Oy644IIYlWrz5s0ATpxwUxWn1157DQAwevRoyLKMxMRENDU1ITMzE5FIBMFgEM8//zwSEhKo/6ri4mIUFxejrq4OO3bsgCzLKCgoQGFhISKRCCKRCCorK1FVVUVPwrW0tKC7uxubNm1Ce3s70tLScOedd2Lz5s1oa2vDxIkTY07rGY1G1NXVweFwIBgM4uWXX8bRo0dpkN+XXnoJ69atw7Rp08BxHMxmM9rb21FWVoZIJEJP/zEYDAaDMawM+1ZsmPnzn/9MvF4vsVgsZOLEiTGn6gg5oTr99Kc/JaNGjSIWi4Xk5+eTTz755KTqiLczlWWZqhkGg4HIskw4jiOyLBNFUYjRaCSKohBFUajBdlpaGuF5nqYNBAL9TuEJgkAMBkM/FUWSJGI2m4kkSboq1OjRo+lrs9kc81ySJGo87Xa7CQDidrtj2hH9W3FNTQ0RBKFfmwRBIIFAgPh8PgKAWCwWYjAYiMvliumz3+8nsiyTQCBAlSxFUYjL5aJG22p5HMfF1KXWpxpzAyCyLJOamhoSCAQGtAkzmUzEZDINqNbp9XewuR6JsLEYGbB5joWNx8jgnLFxiqavrw87d+7Ep59+ir6+vphnl19++akUedaI91toIBDAe++9BwBISEjAlClT6GtFUWC32xGJRPDGG2/E+K9yuVyQZRnV1dUYNWoUDh06BAAghIDnefT29sbUb7fbYTab0dTURO9ZLJYYO6yhonoM7+vrg8FgoHOTkZGBlpYWzJ07Fy+++CKKioqwatUqms/r9SIcDqOjo6Nf/QkJCejq6sKsWbNon6uqqmi5U6dOpeW8+eabAIDe3t6Yvnq9XuzYsQNdXV0x7YqmsLAQGzduxMGDB2E2m3HRRRdBkiQUFhYiFArRPHpjqEXbXxVm3/Af2FiMDNg8x8LGY2RwJuf5pB1gAsB7772HwsJC7N27t5/DS47jBv1QO19Yvnw5QqEQPvnkE5hMJhQUFKC5uRm7d+/G2LFjMWPGDNx99910s2K1WjFlyhQ8+eST+Oijj9DU1ER/ImtrawPHcXS8VOPmp59+Gna7HTabDQaDAQcOHIAgCHC73bDZbOA4Dtu2bUNraytcLheamppgMpkgiiI+/fRTmEwm9PX1gRACq9VKnUY2NjZSI+0DBw5g3759EEUR4XAYCxYswCuvvAKLxYLe3l5kZGSgqakJsizjwIEDOHbsGDWG7+npAc/zSE9Pp04s7777bvA8D7PZjOPHjyMQCGDz5s1obGxEcnIyBEFAc3MzxowZAwBoaGgAx3F45plnsGrVKrS1taG5uRkNDQ1wOBxob2+H0Wik4WoeeeQRlJSU4Nprr8WyZcswefJkfOc738Fvf/tbiKKIW265BX/+85/R29uL5uZmOv7qT3aqM82pU6eyn+sYDAaDMayc0sZp0aJFmD59OtatWwdZlmkMtS8b06dPx5QpU7B161a0tbXhueeeQ3NzM/r6+vDiiy/ijTfeQFtbG02flpaGd999FwCwbNkyNDY2Yvv27bDb7fjrX/8Kp9OJxsbGGNXn4MGDaGpqAsdxGDt2LADg2LFj+Pe//41bbrkFAKht0v79+/Ff//VfePnll9HZ2QkAOH78OK3/6NGjOHz4MK1DtQXLysqi7ayurkZNTU3M5rapqQm9vb2IRCLgeR4A0N3dTZ8fOXIE7e3tqKqqwjvvvEPL6u7uxrFjx/DII4/QzVY04XAYVqsV3d3dqK+vR1VVFR0Ls9mM7u5ufP755zh27BgAUBWru7sb27dvx/bt2/Hyyy/T8niex/XXX4+jR4+ipaUFZrMZwAklb+zYsdi3bx+OHTuGY8eO4fDhw5gyZQrzHM5gMBiMYeWUNk47duzA6tWrMX78+OFuzzlFbW0tqqurAZz40O7p6UFfXx84joMsy/jqV7+K559/nv70lJiYiAULFuDFF19Ebm4uDfS7b98+5Obm4siRIzRobWVlJdauXQtCCEwmE4ATrgry8vLQ3NyMxsZGmjc5ORmtra1ITExEXV0dRFGkP3mlp6ejvb0dnZ2d6O7uRlZWFoAToU0CgQB27NgBQRCoS4XExERceeWVeP3115GUlIS2tjaq+gCA2WxGc3MzeJ6H1WpFe3s70tPTkZiYiFdffRVXX3012tvbwXEcbrzxRrzxxhsxqhpwYgO3b98+GI1GfO1rX8OqVavQ0dGB6upqLF68mLYv2mj98OHDCIfDaG9vhyAIeOmllyAIAlwuF6qqqmiw4fr6etx77720jGXLluHYsWMIBoOor69HfX09urq6kJGRQQMN33rrrV/ksmEwGAzGl5lTMYy68soryeuvvz4cNlbnBPGMyOK5JECU0bL2qL9qCK66H9AL9KvnGFO9BnM7EH1FH9lX6+V5ftDgv1qnnXrhTtQ+aB1hqq4HBiK63MLCwn6OKgfKo22rXngbPSeeWielaj6tE0xmGPof2FiMDNg8x8LGY2RwzrgjULnrrrvw//7f/8PBgwfh8/moYqIyZcqUUyn2nKK2thaRSARutxv79u2DwWBAT08PBEFAdnY2gBMOJydNmgRRFJGUlIQDBw7AYDAgEokgKSkJHR0dkGUZ48ePx9ixY1FZWQmPx4Pi4mIoioK7774bHR0dVDUCQAP4WiwWBINBVFdXw2AwoLu7GyaTibZDDQqsKAoikQi2bt2KAwcOoK+vD0lJSQgGg1i1ahWOHj0Kk8mEnp4eapwuiiICgQC2bduGhIQEcByH1tZWeL1eiKIYoxipAXaLiorwwgsvQJZlGkRXdUDZ09ODsWPHIjMzE0uWLIHH46EuHNatW4fc3Fy8/fbbEAQBdXV18Pl8NCixzWaj9lDZ2dloaGiAxWLBkSNHqN2SCs/zmDx5MjweD5KTk9HZ2YkLL7wQDQ0NdCzC4TD27t2L2bNn44033kB5efkXv3gYDAaD8eXlVHZbqpoQfZ0JB5hfFPEcYEaH+IDGyaKeE0c9xUSrMOmFAVHr0+YfiuPMaGec2rzx8gykYEW3JVpV09ajDRas19fo+obiAFQtb6Agytryo8sdSjwj9m3zP7CxGBmweY6FjcfI4JxTnHbv3n0q2c4r1NNYqh2NwWCAwWBAYmIiamtrUV5ejh/96EcwGAz49NNPcejQISQlJSEtLQ0tLS3o6emhTi3r6uqoipSTkwNZlqnjTDVkCiEEqamp9ESa6rxy5cqV6OvrQ3NzMzIzM5GamkrDmUyePJm2M1p1ys7OxuLFi/Hoo4/i448/BiEEvb29MJlMsFgs4HkebrebtrO3txd9fX0IhUIATrhaqKqqQldXF1pbWzFp0iSEw2EAQF5eHjXOlmUZ77//PjiOo+4aFEVBXV0dtftST/qpTj27urqQkpKCzz//HDzPU5cHFosFRUVFeOONNxAMBvHnP/+ZhrDp6upCT08PJk2aRMvneR4cx0EQBLS3t8NgMKC6uhqBQADt7e2IRCJYunQps29iMBgMxvAy7Fux85CBdqZDDfehXtpguXrqi97/o9Nrg9mqCkxCQsJJRczWCxystQnSC+wbL+CwNpRMtDKkDUUTnVcvCLCe7dZQInZr1TDt+Gvn4mTmeqTBxmJkwOY5FjYeI4MzOc+GU91w7dq1C3fddRdmzZqFWbNm4e6778auXbtOtbhzktraWsiyDEmSIEkSfD4f/H4/IpEIamtroSgKJEmCwWAAz/OQJAnBYBCjRo2Cz+eD1+ulzi95nofP50NJSQkyMjIQCAQQDAbpyThJkpCamopQKIT8/HyEw2EacmTGjBngeR7BYBCRSASTJk2iJ8bUdhYVFUEURRiNRtTX19P2qSqWyWSCLMvo6emBwWCgAX6NRiPS09NhsVjg8XgAnFCcRo0aBafTCUEQUFRUhGAwiGAwiI6ODvzhD39AYmIidUhpsViQmpoKRVFQXFyMYDAIt9sNs9kMWZbhcrmoLyej0UjDyvT19dETfwaDAWvXrsWll16KyspKFBUVoba2FsAJ9W/u3LnUhikhIQEmk4nWE43BYIDT6YQsy8y+icFgMBjDz6nsttavX0/MZjO55JJLSHFxMSkuLiaXXHIJsVgs5I033hjuzd0ZZ6BTdVp1R0/9QBzFSC9objTRdlJaRUerWkUHw1WVGm1QW+3ptcFOzamX9gRadHBdbf/j2R9FK1ZqGdqThnqXy+WKG5hYO17a8daepIu+f7Jzfa6xadMmMnv2bBr2549//GPMczXUUEZGBhEEgeTn55Pt27efVB3ny1gwTo/zaZ7ZumcMF+ecjdOPfvQjFBcXY+nSpf3u33fffbjqqqtOpdhzitraWoTDYUiShOrqamRmZmLp0qXweDwwm83weDy49tprEYlEqP8iNTyIGtxW9QEFAG63Gx6PB1lZWdR3UWJiItLS0iBJElpbW+HxeODxeLBy5Up6wo3jOBw5cgSSJMFoNEKSJPT29qKjowNZWVkoKChAY2MjHA4H8vLy8Je//IUGBa6vr0dqaio8Hg+8Xi8qKirAcRx6enpgMpnA8zyMRiNsNhsikQi1v3I4HMjPz4/pSyAQwN69ezF+/Hh88sknsFqtNAjx8ePHIYoiFEVBbW0tli1bBo/Hg9TUVPT09MBms+HQoUM0oLH6WhRFBINBrFmzBoIg4Pjx47DZbHR8wuEwAoFAzLx4vV7s27cPvb29aG9vhyiKsNvtOHr0KLq7u+kJwfPdf9PRo0cxdepU3Hbbbfja177W7/kjjzyCJ598EhUVFTS4taIo+OijjyAIwlloMYNx+rB1zzgvOJXdlsVi0d3lf/LJJ8RisZz2bu6LJt6pOr2TaoOdjovOH51X659JqwTFO30Xz78R/k8pimdPpVWx4tlqRStD8fwt6flHUtuo7WO0WqW1n9JTsoaiukW3Wau6qfZS8U4/DmWuz3Wg+ebd19dHMjIyyKOPPkrvtba2EovFQn7/+98PudzzcSwYJ8/5Os9s3TNOh3NOcXI4HPjggw8wYcKEmPsffPAB0tPTT6XIc47i4mKEw2Hqebu5uRmJiYkIBAJ44YUXYDQaUVBQgEAggJUrV+qeklM9fLtcLkQiEYRCITz33HP01Fj0abpo/06LFy9GKBRCIBBAfX09DAYDBEGggWvVU2ZGo5H6S+rt7UVWVhYaGxsBALIsIyEhAQcOHEA4HEYoFMLKlSshiiJVt6JP5AHA2LFjUVFRAbPZjHA4jNraWqxbtw6vvvoqnE4nurq6wHEcjQ+XlZWFlpYWdHR0IDs7OyYunKIoWLFiBXbs2IHMzExEIhEEAgE4nc4YRcloNFJbKABobGyEx+PBuHHjsHHjRjgcDupb6sCBA9RG6tixY1RZkyQJHo8HmzdvhtfrxcGDByHLMmpra7+UIVd2796NgwcPYtasWfSeJEnIy8vD5s2bcfPNN5/F1jEYZwa27hnnDKey2yorKyPJyclk6dKl5O233yZvv/02efjhh0lycjIpLy8f3q3dF0C8nWkgECAAiMVioWqI+tu7ehkMhhhFJPpZdBqDwUBVHEVRCMdxJC0tjaYxm80kLS2NVFRUkIqKCmIymfqVE133YH6ROI4jgiDQ/wcCAeJyuUggECA8zxO73U5kWSYVFRW0v4qixJSRkJBALBYLAUAEQRiw74qiEEVRiNvtJoIgkNLSUtpPQRAIz/NElmXCcRyx2+3Ebrf3a69anizLpLCwkLbTbrcTm802YF9dLhcxGAxEEASSlpZGeJ7XPaV3Pn7bhOab9zvvvEMAkAMHDsSk+8Y3vkHmzp0bt5zOzk7S1tZGr4aGhiGNRU1NDSksLCQ1NTUnnWagvMOZ53Tbeabzn2rZQ8k3GOfjmieErfvheDYcz0+1zadT9rm+7k9p49TX10cef/xx4nQ66YbC6XSSJ554gvT19Q13G8848QY4IyODfjhnZGQQRVGI3++nG514H+RGo7FfmoSEBLoI1A2FunmI3oDoGUsDIHa7ndY90GWxWIjP54vZ5MiyTPuSkZERU370T1rqxslut9NNlyRJJCEhgXi93n51iaJIN1Vqn6L7G70Rc7lcuu1X+xXdXr/fT2pqagZ1NxDdv4qKipifGl0ul+6b7nz8EBmuD5AHH3xQd/wGG4uBXGAMluZk3GecTp7TbeeZzn+qZQ8l32Ccj2ueELbuh+PZcDw/1TafTtnn+ro/6Y3T8ePHSUVFBTl48CAhhJDDhw+Tw4cPD3vDvkjiDfD8+fOJwWAgo0ePJn6/nyiKQioqKqiyYjabidPpJKIoUnsjnueJ0+kksiyT+fPnE0mSiMlkIl6vl+b1er1ElmUyc+ZMYjAYYtQUp9NJ3G439cR+0UUXEUEQiM/nI6WlpSQjI4P4fD7i9XqJ3W4nTqeTmM1m4na7ic/nIxkZGaSiooKUlpYSi8VC3G43URSFlJaWkrS0NCJJEs0vSRKx2+2ktLSUFBYWkoqKChIIBIgoijEbYrXffr+fiKJIzGYzmT9/Pm3nzJkzid1uJ6IoEkEQiMViIaWlpaSmpoZuikpLS4nf7yeSJBFRFKmKpy1f3UjV1NSQiooKYrfbiSRJxOl0EqPRSCRJInPmzImJzafW5fV6idlsJj6fj1RUVOh+YzkfP0S0HyC7du0iAMiWLVti0l1++eXk7rvvjlvOSP/mfTbzf1m/eZ9J2Lo//WfD8fxU23w6ZZ/r6/6UFCer1Ur27Nkz3G05a8Qb4IEMl6OVlXihVvScYUa/HuiYvppGq6IMtDOPNizXhoSJfg7oBxPWpoluh9pvPeeTeo4wte0byB2CXvnqa60zUO34q/0dKO9Q5vpcRvsBohrJPvbYY/ReW1sbM5Jl6HK+zjNb94zT4ZwzDr/kkkuwZcsWuFyuU8l+3hAdVqWnpwdjxoxBOBymx+C7u7shiiJ6enrgcDio4bbqTJIQgqSkJAAnwqN4vV4899xz6OvrQ2JiIq688kq8/vrrSEtLQ2trKzo7O2OMwAHQEC4AkJiYiNzcXEQiEVRWVmLVqlXYunUrent7sXbtWkyfPh379+9HMBjEjBkzsHjxYnrcf82aNTS8icFgQH19PYLBIF544QWkpKSgs7MTdXV1SExMpMbXfX194DiOhjNxOBzwer04cOAANU4XBAGjRo1Ca2srMjMzkZmZGWMkXlxcjEgkgp07d4LneRqouLe3F729vUhPT0dfXx88Hg/Wr19PDeUVRcGqVatofwsLCwGA3nc6ndQQvqOjA9XV1TSAsaIomDx5Mq3/fKS9vR07d+6kr3fv3o0PPvgAqampGDt2LO655x4sWbIEEyZMoMeyMzMzMWfOnLPXaAbjNGHrnnFecCq7rZdeeolkZ2eT5cuXk3fffZd8+OGHMdf5xkA706GoS/g/1UOrDmmP4w/kEFOr5ugpVVpnmNq2aB1Zatuv12a9I/8DKU/aPNFqVTz02qDnokBPMdKqbNH34/VroLacL98233rrLd2+zZs3jxDyH0eAo0aNIhaLheTn55NPPvnkpOo4X8aCcXqcT/PM1j1juDjnFCf12Ofdd99N73EcB0IIOI5Db2/vqRR7TqIoCtatW4eOjg6kpKTAZDIBQEwwX6vVilAohN/85jfo6Oigrzdv3owDBw6gpaUFHo8HmzZtAgAYjUZwHIdNmzYhMzMTCxcuRCQSgdfrRUtLC3JyctDY2EiVqZycHDQ1NcFsNlMnmImJicjOzsaePXvQ19cHQgh4nofNZqNuEyRJov8ePnyYzo/BYEBWVhYcDgd1A6Ae91fdFHg8Hjz00EPgeR4WiwVjx46FzWbDp59+CpPJBIfDga6uLmRkZMDpdEJRFBQUFKCtrY0G2V24cCE2b96MxsZGSJIE4ITLg46ODuzbtw9r165FRkYGRFHEoUOHaFBkreuC7du3Q1EUFBUVwePxwOl0IicnB/v370dvby8NDdPQ0EBVwYKCAixZsuS8dUdwxRVXgBAS9znHcSgvL2dhZRhfKti6Z5wXnMpua8+ePQNe5xuDKU7xHDHGs3tClOqhp5Boy4sOhKsX/Fb7Wi1DvR9tYxStisULtaINDRPdzniv1XtqGapdUbzwM3ptUcsaSC3SjoXeOMYLL6OnXJ3MXI802FiMDNg8x8LGY2RwzilOX3bbJgCorKykjijz8vKoI0dVLWlpaUFmZiaam5tRX1+Pm266iTpq5HkejY2NEAQBaWlpcDgcyMnJQUNDA7Xn2bNnD9LS0tDX14fZs2djzZo1aG1tRW5uLrxeL55//nmYzWZkZ2ejo6MDe/bsAQCqFHEch507d1KF69ixYzAYDAgGg/j73/+Ojo4O9Pb2IhgMYt++fWhra0NzczN2794NQgiysrIAAKIoIi0tDZ2dnTCZTFi9enWMc0z139raWqqKHTx4EEajEVVVVcjNzUVxcTHWrVtHbZdSUlJw7NgxFBUV0bpVJ5WyLNOgvqoNVWZmJlpbW3H8+HEUFRXh6NGjEEURFRUVWLNmDRwOB2688UY4HA5q5/Xpp59i7969EEURHo8Hq1evpsGLo22dGAwGg8EYVk5lt6U6aYx3nW/o7UyjT6jpnVwDBg4/og3+G62QaO2CTkbJiQ7FAvRXobQKU7S9k7bteuqW2j4t8U61qeXrla1VfOIpTdo+66XVC1MT3We9cpniNDBsLEYGbJ5jYeMxMjiT82w4lc3W97///ZjrjjvuwLe//W0sWLAA99xzz6kUec5RXl4Ol8uF8vJyKIpC7XiCwSA4joPVakVGRga1OcrJyYHBcGI4LRYLMjMzwfM8AICccPsA4IR908SJE2lIFuCEquP1eiEIAkKhEIqLi5Gbm4uqqipUV1cjNTUVbrcbsiwjFAqhoKAA4XAYbrcbZrMZTqcTdrsd2dnZaG1tRVJSEpxOJ0wmE1paWnDppZeirKwM7e3tMJvN4DgOPM8jOTkZRUVFsNvtEEURgiDAZDIhOzsblZWVKCoqQmVlJXw+H1566SUQQlBdXY26ujpIkkQDD2dmZiIYDEKSJFgsFlx33XV0vGpra1FUVITa2lqEQiGYzWaIogiHwwEA4HkeiYmJUBQF+fn59MSg0WikYylJEmRZRlJSEpKSkuhpPo7j4HA40N7eDrfbDbvdDrfbDVEUaSBmxqkRPW9DfR4vz2BlnU7eU8l3Jp6dqbJPtf+MU4Ote7buh8Rw7cC2b99O8vPzyfr164eryC+MwXameoqT1n4nWrkZSM1Rn6tl6NVBSKziEm2vo/VXFJ1Xe6oPGsUm+l9oVBmtDVH0v3p90Co92pN88fwvxVONtHZK0eXq2U9Ft0Nbf7RaeLJzPZIYzLbvZD0Cx8szWFmnk/dU8p2JZ2eq7FPtfzRszcfC1j1b96fLKdk46TFhwgQsXboU3/zmN/Hxxx8PV7FnlcrKStx3333geZ4Gl+3o6AAAmM1mTJkyBc3NzWhoaEBKSgp6enrQ3d2NGTNmQBAEbN26laY3mUwghGDSpEnw+/148cUXUVRUhKKiIhQXF1N/R9GKC8dxMJvNsNls8Hg8CAQC2LZtGwRBQG9vL0RRRFFREURRhNFoREpKCqxWKzweD7xeL5599ln09vbCYrGA4zjYbDbwPA+n04nm5mZccMEFMafh8vPzYbVasX//fuTk5OArX/kKFEVBeXk5du3aBZ7nkZGRgfb2dhiNRoRCIaxevRrbt2+ntlY/+tGPUFxcjN27d2PdunXo6elBcnIytTeqqqoCgJhAx1rfT2qA4G3btqG9vR0ejwd5eXnYtm0buru7cfz4caSkpKC1tZWe5kxMTKRBfXNycrB371709PR8aQP9nmm0Nm5DeR4vz2BlnU7eU8l3Jp6dqbJPtf+MU4Ote7buh8Rw7sK2bNlCEhMTh7PIL4TBPIdDo9ogSq2JTqM+V1WS6Pt6J73inWTTs5fSU35UWyntCTO9tkOjdOmpQIWFhTHKVHQ7tUqR1tt3dJv06tdTnAb7xqC1M9PabsVT5fTGY7C5HomwsRgZsHmOhY3HyOCcs3H605/+FHOtXbsWK1euxDe/+U185StfOZUih8TSpUvBcVyMHVVnZye+973vwW63w2az4aabbsKhQ4dOu67a2lrIsgy73U7thVSbGlW98Xg8VBlyu90oKSmBLMvo6enB6tWr4XA4IAgCioqKkJeXh4yMjBi7H0VRkJubi40bN6KsrAzhcBgOhwPBYJDaP3EcRxUl9d/ExEQAQG5uLvLz8+lJNY7j0NHRgYKCAhiNRmpzZTAYqF1TWloaSkpKkJ+fj3A4jLq6OiQlJVGP3bIsw2AwQJZl1NbWorKyElVVVRAEAUajEUajEdnZ2TAajVi7di02bdpEPYJzHEfzhUIhmEwmiKKIQCAAURTx6quvwuFwwGKxICcnBw6HA+FwWPf37YKCAvT09MBgMCAnJwd1dXXgeR6CIFBVrKOjA2azGenp6TCZTKivr0dtbS1KSkpgsVjg8/nYN3EGg8FgDC+nsttSVQP1MhgMZNSoUeSWW27pF7l6uPjnP/9JsrKyyJQpU8j3v/99en/RokVkzJgx5K9//Supra0lfr+fXHrppSdVtt7OVFV/FEWJUTZkWSYul4vwPB9zX5Ik4nK5SCAQoPcMBgNNpygK4Xme2O12IggCMRgMMYpV9Ak9g8Gga8+jvXieJ4qixLX70btkWab906ZLS0uLeW2324nRaBxSW6LrLiwsJIqiEI7jiN1uJ4qiEEEQYtKZTCb6fzXIr8vlIqWlpUSW5Zi02rza+qIVJlmWBwwMyb5t/gc2FiMDNs+xsPEYGZxzQX6/aI4cOUImTJhANmzYQGbOnEk3Tq2trcRkMpFXXnmFpt22bRsBQDZv3jzk8vUGWI3OHL3hkWWZ+P1+uhmqqKggdrs9ZiOQkZFBJEkiAIjb7aabLO0GLCEhgdTU1JCKigr6U57FYonZWKjlxNswcBxHFEWJyae2we/3E7fbTcxmM3E6nXQD5Pf7af8URaHtVzdKAIjZbB7yRkkURWK322lb1Y1L9IaO4zji9XpjNofR/48em+hNkNpmr9cbs9FSN5dut5soikJKS0tjxoAZyg4NNhYjAzbPsbDxGBmccz/VqXR3d+OTTz5BT0/P6RQzKN/73vdw7bXXYtasWTH3//Wvf+H48eMx9ydOnIixY8di8+bNccvr6urC4cOHYy4t06dPx4svvojCwkKMHj0ajz/+OP70pz8BANLT01FeXo5bb70V69evR2FhIS699FLqzNFoNMJms6G1tRU5OTngeR7vvfcegsEgBEEAcMLx5AMPPAAA9KcvjuNgsVhgMBhw6aWXwmq1wmazAThxbF+SJOqW4IYbboDJZMJ7770Hv98PjuPAcRwkScJ1112HpqYmfOtb38JFF12Ezs5OTJo0iRqCy7KMu+++G4FAACaTCT6fD4qi4Gtf+xoSEhJwzTXXUFcDDocDRqMRFosFPM9TdwbAiZ8As7OzsX79erz55ptQFAUulwsPPPAACgsL4ff7Ybfb4fV6YbPZMGnSJOrGINqVQ2FhIYLBIHieR25uLux2OyRJwsSJE6EoCn74wx/iggsuoI4zJUnCc889h507d6KwsBDPPfccbr31VmRkZCAQCLCf5xgMBoNx5jiV3dbRo0dJKBQiPM8TnufJrl27CCGE3HnnneThhx8e1p3d73//e+L1esmxY8cIISRGcXrxxReJ2Wzulyc3N5eUlJTELfPBBx/UVU8GO56q5wpARVVK4gXeBfoblw/kvDE6nza91iBcm3Yg1wPxjNkJIbqqj/aZ1ihbLVcdq3iBegdyJzCQmwc9A/ToOqPzDWZsTgj7thkNG4uRAZvnWNh4jAzOOcXp/vvvR11dHf72t79RBQUAZs2ahZdeeulUitSloaEB3//+9/Hiiy/G1HO63H///Whra6NXQ0ODbjo1zEh+fj48Hg/Wrl0LAHA4HNi4cSMqKytp2pKSEiQkJODyyy+HxWJBSkoKDAYDzGYzAFCDa6fTCY7j4HQ64ff7qcG1xWKhCo/ZbEZGRgYkSYIgCDTQrslkQjAYpAbhqrKVmpoK4ISzSJvNRo2qg8EgvF4vzGYz3G43IpEIQqEQVWZUlScnJwcFBQVITEyELMsoKSmh+YxGIwoKChAMBpGQkIBgMEidUwIn3Cy8/PLLGD9+PMLhMPLy8uD1erFu3TqsXbsWycnJyMrKiumzz+eDKIrgeR5JSUnUKD0tLY06upQkCU6nE6+++ipEUUReXh5VqwKBAA36GwqFaOgaNdRKQUEBCgoKmFNABoPBYAw/p7LbGjt2LLUhstlsVHHasWPHsLoj+OMf/0gVFfXC/ykRPM+TN998kwAgn3/+eb/2Pf7440OuJ97OVM/xpVo/oO9gUc8ZZXS+6GP4WpcF2iC1ek4oo4/maxUrbZ3RoUz0jv9rVR49R5vadsdTyNR02rFS50+tRy1b206tMqXOsZovnusGPRWNBfkdGic7FqrdX7Txvd69ge6fKc5UfedK/06nTrbmY/myrPszWde50L/TrfOcMw63Wq10sxS9cfrggw9IUlLSsDXu8OHDpL6+PuaaPn06+eY3v0nq6+upcfjq1atpno8//pgAp28crhp+S5JE3G43kSSJCIJATCYTcTgcRBAEMmfOHOJyucj8+fPpiTBFUUggECBut5sYjUZiNpsJz/PEYrEQs9lM85SWlhK3200AEKvVSutQ65NlmcyfPz+mDbIsk9LSUuL3+4kkScRut5P58+cTSZJo2eqpPbPZTEpLS+miq6ioiPlXNUyXZZl4vV7i8/lonRkZGcTpdBKz2RxjgK2mVfsmiiJxOp3EZDIRURTJ6NGjCc/zdJwAEIvFQmw2GwFAkpOTid/vJ16vl56Us9lsZP78+bRsv99P/H4/URSFXHTRRQQAcTgc/dqtjovf7yfz588nRqORGI1G4nQ66djoxU1kHyL/4WTHYri9Jg8nZ6q+c6V/p1MnW/OxfFnW/Zms61zo3+nWec5tnC677DLy5JNPEkJObJzC4TAh5ISNk6Iow9c6HaJtnAg54Y5g7NixZOPGjaS2tpYEAgESCAROqsyBgvzqKTiqoqEqPFrHklrlRKuIaJUbnuf7OZHUU5oI0VeC9BSqobq41ypSeqpVdL16zivVMqJtrQZyiaA3rnoqUTxHllr1T8+GiilOg/Nl+eZ9Jus7V/p3OnWyNR/Ll2XdM8VpYM65jdPf//53YrPZyKJFi4ggCOT73/8+ueqqq4goiqS2tna42xiDduN07Ngxcscdd5CUlBSSkJBAbrzxRtLU1HRSZcZTnERRpIpHWloa8fv9pKKigiiKQnw+Hz22n5ycTNLS0ojX66U+ifx+P7Hb7WT06NH0Q53n+RgFR5IkYjKZiNvtpiqOJEkxCpbb7SYGg4HMnDmTZGRkEJ/PR10NCIJA5s+fT+vyer0kEAgQr9dLZFkmc+bMIRaLhdapVZlUdcfn8xG73U7LBE64QzCZTMTr9dL+iKJIlS1ZlonT6aRtCAQCNK/VaiUOh4NwHEfS09OJKIr0p9aUlBTCcRxJSUmhGy1BEGhZpaWldA7mzJlDAJD09HTafkIIKS0tJWazmYiiSMdD7YckSdRFAlOcBoaNxciAzXMsbDxGBufcxokQQnbu3Em++93vktzcXDJp0iRSVFRE6urqhrNtXxjxBjj6dJpWqdEqUkNRYaKVkHhKT7RdUjwHmdH39Wye1P9rFSC9E2p6ypmeXZZWEdO+1qpjWnVKe+pQW5f6OtpuLN6JuXjql1aNY4rTwLCxGBmweY6FjcfI4Jw7VQcAbrcbv/nNb/DPf/4TH330EX73u9/B5/OdanHnJCUlJRAEAbIso66uDpIkoa6uDoFAAImJiRAEgYZbCYVCMSFEFEWB0+nEddddR8OhACd8Nnk8Hqxbt46eaJMkiYZxaW1tRXV1NURRRFVVFYLBIFwuF2677bYYP0WhUAiCIKCnpwdVVVVwOBwAgI6ODqSmpsLv9+O6666j/p3U02t1dXUwmUxwu93w+XxITU1FSkpKzEk+SZLg8/ng9/sRDAYRCoUwatQoOJ1OCIKA3NxcmEymmDAr6mk7k8mEnJwcjBo1Cn6/H+Xl5fQUoCAI8Hg84DgOU6dOpWFljEYjDYxsNBoRCAToGKsn7URRxLp163DppZdCURQawiYYDEKSJKxduxbr1q1DdnY2rFYrDVXDYDAYDMawcjK7LDW8ykAXz/PDvrs70wy0M9XzIQTon5DT82Gkqh7R6lW8k3B6io/eyT29dmn/rz21p5YV/X+9Mgayi4pnBxXPjktPIYoeBz1bLb1y9Xw4Rbcp+ll026IDFQ9lrkcabCxGBmyeY2HjMTI4k/P8H4c8Q+CPf/xj3GebN2/Gk08+ib6+vpMp8pxG9ePk9Xpx4MABZGZm4rPPPkNjYyNVR1QVCgCSkpJgNBrh8Xiwfv16SJJEFaiioiI8++yzKCoqAgD85je/gdFoRFFREerr67Fz505IkoSWlhYAgM1mw6FDh9DY2IgFCxagvr4ee/fuxdKlSzF58mSEw2GkpqZCEAQ0NzdDkiS0trZS5Wjjxo1YtGgR/H4/duzYAY/Hg0WLFuHRRx/F9u3bqVKVnZ2N1tZWJCcno6OjAx6PBwUFBWhsbERDQwNMJhPuvPNO5Ofno7GxEQ6HA7Nnz8aaNWvQ0dGBrq4uqpwdOnQInZ2dGDNmDBoaGuDxeAAAiqJg48aNEEURLS0tkCSJ+psaM2YMurq6sHfvXur3qqenB4IgYMqUKVAUBStXroQoijh48CC6u7tRX1+Pm266iXo1F0URnZ2d1Ku5yWTC8ePHEQwGz87CYTAYDMaXl9PdeX388cdkzpw5hOd5cuutt5I9e/YMx4buC2UwP07RypKefyYMoJLoKVDRKkm8023aU3fq/weyadKzl9KiVZmiT/DF8/KtrVNPzYoeF62fq//f3tlHN3Gd+f8raSSNPJLHtiQs2WA7liNeKkFocCxR2u4uqcZ9oUmbs+zGPk0iUrpk0+TUbJek59cS4nLatN2WnuRk+7ohdgvJUkpCE7oxKaR00xIHp0kMhQQSOUAAG1vBBtvYRuj+/mDnRhpGfgGDbfR8zpkTe+bOfX0crh499/uoY9NTNC/9v1x+enM53Lxp+6lN9ptp/PRp80NoLrIDWud0aD6yg0kZ43T8+HEsX74cwWAQiUQCb7zxBhoaGlBaWnqpVU4qGhsbsXPnTlRWVvL4pdbWVuTn58NgMMBms8Hn88HpdMLn8/HYoEAgwHP35efnw2w2Y+/evfD7/SguLoaiKIhGo7BarQgGg6irq0NLSwtisRjcbjeP2enu7kZxcTGsViuPPXK5XIhGo9wLVlBQAIfDgaqqKkQiEdTX12Pp0qWora2FKIpwOBxobGxEMBiEzWbDww8/DEVReLySxWKBxWJBMplEWVkZbzcQCKC4uBgAYDAYMH/+fN6mJEnYtGkTdu3aBUmSYDQaIQgC8vPzIUkSV0F3uVyor69HS0sLWltbuTfJaDQiPz8fgUAAsiyjt7cX8+bNA3Ah1sloNPK4rF27dqG1tRVutxuKoqC+vh6yLMNisfA4qtRYrNraWl6HLMuor6+fMPshCIIgrlHGutPq7u5mq1atYjabjYXDYfanP/1p3HdzV5vhdJy8Xq+uF0Z9plX7zuSRUsvqxT+leoGMRmOaF0dRFP5MURRWU1PDY8nwf54WRVH4tWbNmrQ+qOKT6qUKU7pcroxeM5fLxaUF8H8eL6PRmHZP7zIYDLyM1+tljDEWDod1yyqKwrxeLx/zSLpPqj6Y9p1wOMw1PlK9YEajkeQIRiDTXFyOhstYNVdGKj/e9V2JNkfz7tXSv9Frh2w+nclu95diKxNp9+M9lkvhatv9mDZO3/ve91hBQQGbM2cOe/bZZ8e9MxNFJh2n0tJSFgqFmMlkYh6PhzmdTgaAWSwWZjAYWCgUYjU1NVzbyev1MqPRyFXGi4uL+T/katk9e/ZctMh79uxhHo8nbXMjiiIzGo1MURTm8Xj4BmnPnj18Y2S1WvmGSbtxUzcWmTYk6lj0viZTn6XWM9xXlKqelaoPpY6XMZY2rtS2FEVhoVDooq/3tJtH7cYp9R0AzOPx8E3onj170vpOX9UNz2hSDY3m/ljLjKX8eNd3JdoczbuXU+fltk82n85kt/tLsZWJtPvxHsulcLXtfkzB4Q8++CBsNhsqKirQ0NCAhoYG3XJbtmwZS7WTkjvuuAMAsHLlStjtdpSVlaG6uhqPPfYY+vr6YLFYUF1djYceeggtLS3o6elBf38/cnJyIAgCHn30UbS1tWHt2rVgjPEAbpV4PI777rsPACDLMg/cPnv2LO666y4sWrQIq1evRk1NDcLhML7//e+jpKQE69atQ21tLX71q1+hoqICNTU1+MEPfgCz2YwZM2YAuJAcecaMGZg2bRreeecddHZ2wmw2w2Qy4dy5c7Db7bjvvvtw8OBB+P1+/OAHP0BfXx+sViuXX7j++uthMBhw8uRJHDt2jAexHzp0CHl5eTyIvaSkBIwxxONxHri+bt06noR3yZIlaGxshN/vx9e//nVs3LgRALB27Vr8/Oc/59ILN998M7Zv387bOXDgAP/K02AwoKWlBY2NjaiursZf//pXmEwm5OTkYMmSJdi+fTsURcH+/fsBXEh2TMHhl05dXV3af0e6P9YyYyk/3vVdiTZH8+7l1Dke7RMjM1ns/lLWcCLtfrzHcilcdbsfyy7rzjvvZHfdddeI11Qj085UL7UHUrwdqcHP0HhISktLLyo/nAilnlSANig6UyJg9T29I/7aPukl89W+rxcArheUru1L6m5fL7Bei15KFW0g+XBzqpWEyLQ+o1nrbITmIjugdU6H5iM7mDQepyeffHIsxac89fX1WLlyJQYGBpBMJnH06FEYjUaYzWYwxhCJRBAOh3Ho0CEUFxejvb0d58+fh8FggCAI+NKXvoTHHnsMiUQCLpcL3d3diMViuOeee7B37168/fbbyMvLQ3d3N3p6egBcCJCORqP47Gc/C+DCDtrv9+ORRx6B1+tFRUUFwuEwr9fhcCAQCCAej6fJBCQSCSQSCX5U32azQRAEFBQUwO/3p+3QW1tb8fbbbyOZTCI3NxclJSWIx+OoqalBLBbD4cOHeaD1tm3bcObMGbz//vsoKysDABiNF84YNDc3o7q6GmvXroWiKHjppZfgcDiwePFiKIrCZQ5OnDgBv9+P2tpa7o1KfS7LMp8Xk8mEkydPQhAERCIR7N69G4IgwGQygTGGsrIydHZ28jpWrlyJ3t5e8jgRBEEQV4Zx34pNQcYqgKmXhFfPQ6L1wKR6ZtR39WKM9L5j13qC9FKL6PVVe+mJXGo9Zpm8RNpymVK0pPYxk5ct0/xo69FKF2jr0KZq0XrJxrLW2QbNRXZA65wOzUd2MCnlCLKBlpYWNDc3AwDy8vJgMplgNptRW1uLxYsXw+FwcJmAyspKABe8L5IkIRgMIh6P87ic5uZmmEwm5ObmckkCURS51IDFYgFwQfhS9ca0tLQAuOAVCgQCsFqt3LNSWFgIn8+HgoICxGIxNDY2Ih6Po7y8nItCqkf1g8Egl00oKChAc3Mz8vLyYLfb4Xa74ff7EQqFIMsynE4notEoFi9ejFgshurqajz88MPwer1obm6GLMswm80AgHnz5kGWZZhMprT2YrEYWltbeeqXurq6NBkEk8kEk8mEvXv3orGxEbW1tfD7/SgoKIAkSZBlGaFQCIqiwOv1cvkHVXrB5/PxtaioqIDBYOAxaGpqHFUOgSAIgiDGlXHfik1BhjtlgRSPUmrckdajoo3XSY3xyZQyRCtiCZ24HRXVQ5Ua/zRa8cvh+gJcLFSp7Z9W3FKbNiWT10t7Xy9Fip7w5nCxYNpYJtXjZDKZRkzwO9xaZyM0F9kBrXM6NB/ZwaSJcco2/H4/T9CrJvpVxRgBYOfOnVwYM5lMwmAwoLi4GPF4HOFwGMXFxYhGo9i9ezdPYTI0NITNmzfjM5/5DAoLC+F2uyFJEnJzc3HkyBEe47R7927EYjEEg0GcOHECg4ODAC4k8VXbVxSFn1KrqanBxo0bsW/fPgwMDKC/vx95eXlIJBIQBAGVlZUIBAJoaGiAwWBAIpGA0WjE+fPneSyQoij485//DEVReFqXQ4cO8WTCbrcbyWQSfr8fL7/8MiKRCNrb23Ho0KG05MbqvAAXYqJsNhvOnTvH46xkWeZpVaLRKA4ePAhFUfCTn/wEBw4cwPnz59HU1IQvfvGLfI4A4MiRI9i2bRvsdjuPcVLXRxAEGAwGnDt3DqWlpYjH42hpacGCBQuuqs0QBEEQ1zjjvhWbgox0qk57Wk3rUUIGD4rWa5TqwTKZTLqpToaLCVKv4XQ49Mqr/dKeSNN6p7RaGFrvkZ5XSy+WKdMpt9S2M82PXjoY7Qk/7aWNtdImGR7NWmcjNBfZAa1zOjQf2QHFOE0Q9fX18Hq9sFgsYIzBbDZDkiQ0NTXhtddeQyKRgNPpRCgUgiRJsFgsiEQiWLp0Kerr61FZWYmmpiZMnz4dNpsNx44dg8lkgiAIKCsrw+LFixGNRiFJEo4dO4aioiJ4PB74/X6exFdN52IymQBcSOPS2tqKoqIiNDY2AriQHiY1BkmSJEiSBFEUYTKZYLfb4fV6EY1G4fF4eCqXSCQCr9cLh8OBcDjM44N27drF+2uxWHiamfnz52Px4sXIz8+H0WjkqViqqqoQCoXQ2tqKpqYmVFZWQlEUCMKHDk2DwQCTyQSfz4dIJMLT2DQ3N/Ox+P1+WCwWFBcXw+v1ora2Fm63G7FYDIqi8HlWU7wAgM1mQ0FBQdq6SZKExYsXk5YNQRAEMf6M+1ZsCjLSqTqMcEqNsfQYJBU9HScM40lJ9UJl0lPSnjLTtqPVWNLzfmnjpTJ5tlK9TWrb2r7ojUOtN7UuvdNvqe2mesS0ZVJjlrSnETMpmlOM08jQXGQHtM7p0HxkB+RxmgBaWlpQXV2NWCyG4uJiHr8UCATg9Xoxc+ZMABdO0dntdnR1dcFut6O3txeNjY1oaWmB1+uF0+lEcXEx976o3h/1NFs8HofP5+PJfN1uN6LRKKqqqviJuf7+fgDgWkbqKTw1sW0kEoHVaoXD4QBjDO3t7Zg9ezYcDgdPHKx6blTvlNfrRX19Perq6hCJRNJO3omiCIPBAJfLBbvdDsYYgAuenNbWVkiSBLPZDK/Xi9mzZ2Pnzp38NJzFYkE0GoWiKHA6nbBarTAajVi0aBHXspo7dy5isRiqqqr4fEaj0bR+tbS0cG9WJBLhyZDVJL4VFRUwmUyorKyE0+nkpxJNJhNPnkwQBEEQ4864b8WmIHo7Uz1vRyp6nqhUr4o2Xkj1jKinv7RxPpl0jwwGQ1oMj95JOvW/2vgg7am9TLFF6nj0TrfpaTWp13An7LQK4HoxVnpK49pTeMPFiKntjqRQPtJaZys0F9kBrXM6NB/ZAXmcJgBVd0jVE1J1lVpaWlBbW5sWc2MymSDLMpYtW4bS0lJEo1HE43HujXn44YdRXl4Oq9WKsrIyHhOkek+am5vTdI8URUEsFkNubi5yc3PhcDgAXPBuRSKRtMtoNKKjowN5eXn4xCc+wcupKuChUAj19fVcByoSiaCyspL3q7q6GsFgkMcmRSIRHssVCARQVlbGdaFUz5nVaoUgCCgvL4fX64UoitzzFYlEoCgKH7/BYIDZbIbD4UA0GoXT6YQkSVzBPBwOw+Vy8fgs9XSe3++H0WjEli1b4Ha70djYyPWsRFHEokWLIIoi8vPzIUkSurq6IElSWswUQRAEQYw7474Vm4Jk8jileoP0ftaW076b6o3RekX0PFajOVE3XAyVXr16cU16pwW1z9Tf9WKj1EtPx0lv/Hpj08ull1pO77Riat2ZNKn05mmktc5WaC6yA1rndGg+sgPyOE0AdXV1WLx4MeLxOPx+PyRJwubNm9Hf34/i4mL4/X5UV1dj165dSCaTkCQpLS5q8eLF3COknnLr7u6Gx+PBb37zG7z44osXKXyHQiHE43EoioJAIABBELhKt8lk4h4ntZ1EIsH7O336dN5Xp9MJWZYRDAb5CbxIJAKXy4Xe3l7Mnz8fOTk5qK2tRVVVFT8RGI1GEY1GeS44VbtJkiSUlZXx+Cqj0chPtomiCADYtWsXqqur0djYiFgsBlmW4fV6IcsyrFYrAGDz5s3YunUrRFGEIAhIJBL82bRp03isV39/P3p7e/kzq9WK7u5uLFy4EIqicNX2goICBINB+Hw+fhpPlmXetqq8ThAEQRDjxrhvxaYgmXam4XCYAWAul4t7MoxGIzMYDMxsNg97Cq2mpibtvUyXIAjM6/WyhoaGUWkgeb1eXW+L+rvFYmEAmNPpZF6vN807pPZHFEVmMpmY1+tlTqeTl1EUhSmKclGb6ljtdvuI49H2y2g0jviOOg+pKuCZytXU1KT10eVyMUVRmMlkYqIoprXp9XrZnj17RrXW2QjNRXZA65wOzUd2QB6nCaKtrQ0AwBjjsTXXXXcdGGM4d+4cAHB9JbUcAHg8HtTV1fHfAXBPjZZEIoETJ05g9erVqKurw9KlS1FXV4f6+vqLyufk5KC0tBTvv/8+CgsLEQgELmp7aGgIABCPx3HixAk4nU6IooiBgQFepqKiAtOnT8eJEycQj8dHnIdkMgkA6Ovru+iZJElpv6ttqO2q7wLgnqbUOVHnL5FI4Pz58zCZTFi2bBlycnLS6rVarQiHwxedluvq6gJwweM2MDCQ1t8TJ05g3bp1I45vKrJmzRoYDIa0a9asWRPdLYK4opDdE5MBSrkyDCtWrMB3vvMdDAwM4NSpUwCAYDCIvr4+mEwmdHR0gDGG4uJidHZ2YmhoCIIgIB6P4+c//zm8Xi96enogSRIeffRRAMD999+Pvr4+5Ofno6enB7Iso7u7GydOnMDChQsxa9YsbNu2DevXr8fdd9+N5557jn/Nl5eXh0OHDuGmm27i9d133304cOBA2td2giDAbrcjHo/jvvvuw2c/+1ncf//9OHjwIMLhMB599FHs378fDzzwAA+sFgQBNTU1aGtrw86dO2GxWODxePjGpKSkBO3t7ejs7ER+fj7fnMyYMQPAhU3m2bNnecJdt9uN3t5enmZmaGgIxcXFPH2KwWBAbm4ubDYbnnvuOcybNw/79+9HRUUFFi1ahN27d+Ott97iMg19fX1YsWIFFixYgLVr1+Kdd95BLBaDJEkIh8Po6elBb28vRFFEZ2cnZsyYgYqKimtaluAjH/kI/vCHP/DfUzelBHGtQnZPTDjj7sOagowmya96pYpUpt7XCjFmSjqbGtysV09qXcOJRWrr0ibD1dahJy6pJ7apLZcaSJ7avl46Fj35A20C5ExyBVr5hOHmI7Vf6v2R5BZGWuupxkMPPcTmzZt3WXVcK3NBDM+1tM5k98Roydqv6r773e+isrISDocD06ZNw6233oq33347rczAwADuvfdeOJ1O2O123Hbbbejo6BiX9lURRzV42mAwoKCgALIsw+FwcGHM6dOn89QseXl5/Ah+IBBAMBjkQpZq4LQqSql6m3w+H5c1MJvNPBWKIAjIy8vDtm3bUF5ezoUnHQ4HHn74YezcuROlpaXo7e1FIBCAz+eDIAjIzc1FJBJBTk4OIpEIqqur4XA44HK54HA4UFFRgU2bNkGSJESjUUQiER6YnipE2dLSgmg0ygPJA4EAzGYzOjo60oK8VVmCQCCAgoICnkBYDX4vLy+H2WzmMgZ79+6F3+9HcXEx76caqK6+r77r8/mQTCZ5ipfq6mqEw2E4HA7Isgyn04mysjIuUOr1elFZWcmT/F7LHDp0CEVFRSgvL0dtbS2OHDkybPnBwUGcPn067RoJVX5Dby4zPRvunYmo81LbHGuZ8a73csd8rUJ2f2XbHGuZSyk/5e1+3Ldi44iiKGz9+vVs37597I033mCf+cxnWElJCevt7eVlVqxYwWbMmMF27NjBWlpaWCgUYgsXLhxTO8N5nDIdfdcKUOp5S1I9M3peGWg8SKneHq23S5toOFVQU31HT0pAzxOUWq+erMJwP6e+m9q3TPOU+iz1fmr/tCKgqWVS39POgfpualD5SGKY18qnzd///vds06ZN7M0332QvvPACC4fDrKSkhJ0+fTrjOw899JDu+g03F3pyGyM9G+6diajzUtsca5nxrvdyx6xyrdg8Y2T3Y6nzUtsca5lLKT/V7X5Sb5y0nDx5kgFgu3btYowx1t3dzcxmM/vNb37Dyxw4cIABYLt37x51vZkmuKGhgXm9Xubz+ZjT6WQ+n49JksQEQWDBYJCfhFuzZg1zOp3MbrczWZaZ2+1mRqOR+Xw+1tDQwEKhEPN6vWzNmjUX/exyuVggEGCKorDly5eznJwctmbNGrZmzRpmNpuZKIrMbrczSZKYLMu8L2obdrudGY1Gduutt7JAIMAEQWCyLLPly5czp9PJZFlmwWCQ12+1WlleXh4zGAxp/ZNlOa0d9Z01a9aw0tJStmbNGqYoCnO73fzkmtvtZhaLhfl8PqYoCq9LkiR++lDtX3FxMSsuLub31PpCoRALh8MsFAqxQCDAnE4nKy4u5vX6fD4mCAKz2+0sEAiwYDDInE4nC4fDrKGhgSmKwusVRZGFw2G+Hk6nkzU0NIxqrac6p06dYrm5ueyXv/xlxjIDAwOsp6eHX0ePHh1xLvbs2cNqamouOp043LPh3pmIOi+1zbGWGe96L3fMKteqzTNGdj/Z7H605ae63U+pjdOhQ4cYALZ3717GGGM7duxgANipU6fSypWUlLAf/ehHo653OI+T1sOh9RJpy6V6gVSPTiYPTuq72vgcPcFK9Xc94Uc9b0wmj1am5LyZPEWp/82U/kU7nkx16s1L6pj0PGN66WUytaf11qWu03BrfS2wYMEC9uCDD466/LU8F8SHXOvrTHZP6HEl13nKHEdIJpP42te+ho997GP8GH57ezuPK0qlsLAQ7e3tGesaHBzE4OAg/z3Td96KomDnzp38JNiRI0cgSRJsNhsikQjKysoQjUYRi8VQUFCAZDLJT9+ZTCZYLBZs27YNJSUlqKysRCwWAwAsXryYJ62Nx+MIBALo6upCJBLB9u3b0d/fj/fff5/HO+3duxfd3d0AAIfDgaqqKnR2duK9994DYww5OTkoKyuD3W7H0aNHcfz4cSQSCQQCAbS3t8Pr9aKxsREOhwOCIODs2bM81Yt6hB8AzGYzkskkbDYbysrKcPbsWbz33nuw2WwQBAGyLOP8+fPo7u6G2WxGV1cXcnJyIAgCT5Wizpl6ElAURbS3t8NoNGLWrFl444034HK5sHPnTqxYsQJLly6FoijYuHEjP2VYUlKChoYGmM1meDwedHd38/b7+/uRTCbR39+P6upqvPPOOzAajXweBEHA1q1bYTKZYLfbUV9fPzZDm6L09vbi3XffxZe+9KWJ7gpBXDXI7okJYdy3YleIFStWsNLSUnb06FF+b8OGDcxisVxUtrKykq1atSpjXaP9zjvVO5TqwUn9PfU02HDCjZm8M6Px6uidfkv1qqR6kEbj1Rnu0osdGs2lPS2X+v2zOldagUutN0hbPnXetO2lnlpMnQftnGu5Vj5t/tu//Rv74x//yNra2tif//xndvPNNzOXy8VOnjw56jqulbkghudaWmeye2K0ZO2pOpWvfvWreP755/HSSy9h+vTp/L7H48HQ0BD3xqh0dHTA4/FkrO8b3/gGenp6+HX06FHdcoqi8PQqDocDFosF06dPx86dO1FWVgaj0chPgDHGUFFRAbPZDJvNBlmWUVxczFOldHV1oaioCGazGc3NzaiuruYny6LRKJYuXYr6+npUVlZCFEWYTKa0tCfz58+HxWJBeXn5RalVvF4v3G43FEVBJBIBcCGFSSwWQ2trKz/FV1hYyL1zBoMBkiRxb5rVaoUsywgEAvzEWkFBAQwGA6ZNmwan04lgMIhAIACn0wm32532LBAIIBaL8XQzamLkxsZGeL1euFwueDweWCwWFBQUcI/V3LlzUV1dzU9ItLS08LkOBoOor69HVVUVbDYbDAYD8vLyIIoili1bhkgkgkAgwE89qicV1aTL17K36f3338ftt9+OmTNnYunSpXA6nXjllVf4ehLEtQjZPTEpGPet2DiSTCbZvffey4qKitjBgwcveq4Gh2/evJnfe+utt8YtOFzrrdE7zQaNF0T7nva5+nMmzSGtd0YtmylhsNYTpfWEadvSaippvV3DxRfp9VN7ok7recpUb+qlFyOlvaeWzcnJuWj99NYp04kL+rT5ITQX2QGtczo0H9lB1sY43Xvvvdi4cSO2bt0Kh8PB45ZkWeZenbvvvhsrV65EQUEBcnNzcd999yEcDiMUCl12+4qioKmpCd3d3QgEAigqKkJJSQk2bNiASCSCl156Cb29vTh//jwAYN68edz7U1lZCcYYHA4Hjh49CrPZjC984Qt47rnn4Ha7YbfbwRjDrFmzoCgKqqur0dPTA4fDAa/Xi8997nPYsmULEokE5syZA0VR8LOf/QxOpxMOhwMnT57kWknl5eU4cuQItm3bhqVLl+KZZ57BuXPnuD5UZ2cnjhw5gtmzZyMUCmH9+vVgjMHr9XJFclEUkUgkIIoiCgsLeXJfWZZRU1ODn/70p2hqasLcuXMhSRIEQYDFYuEq4pFIBFu2bOHzNmfOHLS1teHgwYOYPXs2JEmC1+sFAHR2duLw4cMwm80wGo0QRRF+vx/BYBAHDx5EUVERent7sW3bNixcuBArVqxAc3Mz2trauL7UggUL0NLSgnXr1kFRFMTjcRw7dgwFBQUoKiriOk4LFiy4bDsgCIIgCM64b8XGEWSIp1m/fj0vc/bsWfav//qvLD8/n+Xk5LAvfOEL7MSJE2NqZzTK4Wq8jPaEXGq/tOrao9Wi0J7e08ZA6cVEDddu6ok8xliatyq1z5limPT6rW0z9dJTC099JzUGS3tfT9dK26/UOco0P5lOJ452rbMRmovsgNY5HZqP7CBrY5zYBbmEi6677rqLlxFFEY8//jg++OAD9PX1YcuWLcPGN42Furo6hEIhrvC9cOFCHpckSRKampq4VyYcDmPVqlXweDwIBoO6CtYtLS0Ih8PIy8vDli1bwBjD1q1b0dzcjIKCAh5D1NTUlNaWoij8BN7s2bPR29sLn88Hp9OJcDjM45oEQUA0GkV9fT1KS0sRiUTgcrmQTCZhNptRXl6eFhtVVlbG44NKS0sBADNnzuRtplJfXw+n0wmr1QqDwYD8/HwIggCbzYbc3Fw4HA6Ul5fDarWiv78fZWVlmD9/PlcFV5MXqzFMoigiEolwT6GqFG61WrFkyRKePNhut8Pv9yMWi/FTc7t27UJeXh5XL29qakJ/fz+PAXM4HDzmiyCuFSaFYjJBXGUmpd2P+1ZsCjLcznTPnj1MFEXu/RBFkZlMprR7siwzURTZmjVr2J49e1hpaSkzGo3MbDYzACwYDLI9e/Ywr9c7rNdGURRmsVjS2jIajczr9TKn08kMBgOzWq0MALNYLCwUCjFFUdLqtdlsTJZl5nK5mMvlSvPiGI1G3u/UdlIv9b4q0llaWsoaGhpG7D8A3rfUMZlMJqYoClMUhfl8PmY0Gof1eKmCmdp6hms3Ux9KS0svEkmjT5sfQnMxtRirirMKrXM6NB9Ti8lo97RxYsNPsJ5AZGlpKQsEAhdtQNSvwkwmU9rXZ1arNaPQpLop8Hq9TFGUi55rj9jLsnzR+6FQSLduVT3bYrGkBZiP9kpNrpvaf1UJXFte2zf1XUVR0jY/qRs4q9XKnE7nqPojCELafAuCwHw+30WbWHXTaTKZLvpjo/9pfgjNxdTiUtTLGaN11kLzMbWYjHY/qYPDJxI18Njv98Pj8fBg6b6+Phw5cgQdHR2wWq349Kc/jZdeegkDAwOora3F3r17kZeXB6/Xi46ODnR2diIUCiEWi8FqtWJwcBD5+fkYGhrC0NAQpk2bht7eXgwNDaGkpIS3YbPZUFFRwX8/fvw4/H4/VqxYgf/4j//A/v37AQBOpxOvvfYa7HY7ent7ed3nzp3DF7/4RfT19UGSJGzYsAGVlZV45ZVXIMsy4vE4kskkBEFAMBjE66+/DgDIyclBeXk57HY7Tp48iaNHj8LhcECSJBiNRiSTSTidTsyYMQMAcOzYMQDA9OnT8eUvfxnr1q3DmTNnYDAYYLPZ4PV6UVNTAwB45513cOTIEeTk5GDp0qXYvn07otEodu/ejePHj+P48eMQRREnT55Mk2MYHByEIAj43Oc+l3b0uLOzE6tXr0ZbWxu+853vwGKxwOVy8eTI/f399HUdcc2wYMECbNiwYaK7QRBXlUlp9+O+FZuC6O1MtcfptYHN6qVNX6KXiDdVGgD/9xWVXrC1noDjSH3Qfo2leodS39NKKGi/ItO2O9xYUtvVBqVrA7T1pAG0get68zxSELw2AD5T4LqeLAF92vwQmovsgNY5HZqP7CBrg8Mnkrq6Oi5KqQ1sFgQBoihClmWUl5dj8eLF8Pv92LZtGwwGA4xGI3JzcxGJRJCTk4NIJILc3Fxe95IlS1BWVsZ/F0URLpcLq1atgs/ng8FggM/nQzQahSzL6Orq4kHQjY2NiEajEAQBZrMZ8+fPh8Fg4AKWXq8XiqKgsrISvb29qKysxKpVq+D1elFWVsaFKA0GAwAgPz8f5eXlkGWZB7nX1dWhrq4OkUgEoVAI5eXlGBwchNF4wVzMZjNCoRBWrVoFWZZhNpvhcDjg9/tRUFCAgoICLripCmMGg0EUFRUhEomgtLSUi1OqIqOqeOXWrVuxbds2zJ49G16vlwtuBoNBWCwWAEBZWRnq6+vh9Xrh9XoRjUbh8Xi44KjJZILP50MkEkFdXd1VshiCIAgiKxj3rdgUZLQ7U+2R+EwpWYCLPUbQeHT0jtwzlu6RyRRflcmzk+q1Gc6zoxW4HCnwTvX0pL6XKUmxti/a9tT+pM5pJu+SVopBfaYVGtXzxGnbGetaZwM0F9kBrXM6NB/ZAcU4TQJaWlp4Ml+/34+6ujrs378fL730EmKxGMrKynD48GGYTCYAAGMMHR0dEEURXV1dMJvNOHfuHIxGIyRJgsfj4fFBNpsNsViMJ+K1WCzcg+N0OpFIJOByudDT0wNBELBt2zbk5ubymCiv18uT427YsAF+vx/9/f04fPgwT4ZbU1ODtrY27N+/Hzk5OTh37hwSiQQ+/vGP44033sCmTZvQ3NyclrpAlmWsXbsWq1atwiOPPAKn04ne3l4AQGtrK6qrq2Gz2cAY42llAoEAF/csKioCYwyJRALHjh1LS7rb2NiIlStXYmBgAJIkweFwIBAIoK2tDefOnUNtbS36+vpQV1eHxsZGNDU1wWKxIJFIYMmSJaiursaxY8fSZAfq6+uxcuVKGAyGazrdCkEQBDGBjPtWbAoymp1pJgFGbYLf0STG1cY8Aekikqm/Z/K6ABeLbKZ6mbSpVbReMb1kwdorU0Li1D5qx6sXY6WWSU2XovUwjSZJMDLEX43lmCp92vwQmovsgNY5HZqP7IBinCaIxsZGlJWV4eGHH0YsFkNubi4KCgq4h6Ourg5VVVXweDxYsmQJcnJysGzZMi6aKUkSF8hU428EQeCxO2rqGDXeaP78+YhEIvD5fDCbzRAEIS1hrqIovG6n04mysjIkk0ns2rUL4XAYDocDLpeLn4IDLsQwSZKETZs2IZFI8H7l5ubixRdfxMaNG3lclhobFAqFEAwG4fF40sa6dOlSRKNR5Obmwmg0wmg0wuFwAEBajJUq3KkmJp43bx5MJhNqa2sBIC1WTJIkFBcXw2w248UXX8SmTZsgiiJisRgefvhhLqRptVoRDAZRV1cHRVFQWFgIn8+HgoICtLa2IhgMwuVyoaKiAnl5eXC73WhsbLzaJkMQBEFc64z7VmwKkmlnqk0NAh0Px3DiXHopU7QxQKmeIdUjo+dh0Uu9ovX2aL1Wap3QeIT0BCXVurQxUdpx6XmoMiX21SYmzpTIVy8+TC+x8XDeL71LL86JPm1+CM1FdkDrnA7NR3ZAHqcJIhqN8lNxqodD9cK0tLQgGAxi06ZNkCQJfr8f1dXV/PRYY2MjJEnC+fPn8fTTT2Pu3LlQFAWRSASRSATRaBRutxterxeiKMJoNHKPTDQa5R4Wv9+PnTt3orKykp/si8fjWLx4MZYtWwar1Qqfz4dgMIiCggI4HA5UVVUhEAjA6/UiEonw03D5+fno7e1FUVERBEHg8VhWqxUejwcWiwWCIKC6uhqKomDx4sX8RJzdbocoipAkCaFQCKIowmAwYNq0aSgoKEAsFrvoNF9tbS1EUUR+fj5kWUZzczNPN6PGesViMX4qzu12c/0ntf9qImG3283nuLW1FW63G2VlZTAYDCguLuYePEmSIIoiLBYLotHohNnOVGW06Q1GKjeaevTKXOp7o+VS+z3W+1fqvcutl9BnPOz+cuogux/5+XjM/bgx7luxKchwSX5TPSharSEM48nRxhkh5SSaWnempLR6p/VG4wkaTjcJ0Ndx0vNSpXqQtB4drVcsNVGvtr9672uvTDFjen3KFF+l9imT9tZo1jobGU6/bKS4sZHKjaYevTKX+t5oudR+j/X+lXrvUuolm0/nStn95dRBdj/y87E+o1N1E4SqAeT3+/HYY4+hu7sbs2fPRiwWAwC43W50dnbCZrMhEongueeegyRJ6Onp4TE8sVgMjDGYzWa0trYiHA5DlmXU1NQgFovh8OHDiEajOHjw4EUeJUVR0NzcDKPRCEEQ0NjYiNbWVgiCAL/fj5aWFnzzm9/EsWPH0NbWBkEQUFhYiG3btiEvLw9z5sxBJBLBf/3XfyGZTKKiogJtbW0wGo2w2+0oKSlBUVERbDYbjh49iry8PJw5cwaSJEFRFLz88sswGAxwuVw4c+YMhoaGUFtbi8bGRvT29sJut8NkMsHr9XKVbkVR8OCDD+qe/uvs7MR7770HxhgMBgMsFgtMJhO2bduGhQsXYsWKFYjH4wCAmpoa1NfXIxaLoaioCCUlJTh58iTMZjNmzJiBiooKfopw1apVaGxsxNatW5FMJjF//nzMmjWLJ1hesGDBhNnQVEO1+ZH0r0YqN5p69Mpc6nuj5VL7Pdb7V+q9y62X0Gc87P5y6iC7H/n5eMz9uDHuW7EpyEg701TvUqqHRaucrVUQ1zt1hhFOkKXe0/Nqpf6s59HRO+Wm9yw1/ieTTlNqjJGeFyzV4zPc6T+tR0k7H9py2n6p7ev1XSV1nNr+jmWtswmai+yA1jkdmo/sgGKcJhhFUbhCtnoizuPxIBKJcAXvSCSCpUuX8pikQCCASCSCZcuWwWKxQJIkBINBHoukxgQtXboUiqKguroa4XAYsVgMixcvhiRJ+O1vfwu32w1BEGC32yEIAlf4VhXC1VN4JpMJZrMZixYt4vFLPp+P/2w2m7FkyRI+jkQigby8POTl5UGSJBgMBjDG+HuxWCztZJ7f74fb7eYeL7PZzE8JGgwGlJaWoqmpCQsXLoQkSbzd6dOnY9u2bTzXX0FBAT9laLFYUFxczMekalmFw2G4XC6eH6+yshJbt24FAJ7/7uGHH4bL5UJeXh4WLlyIaDSaVj41LowgCIIgxo1x34pNQfR2pqkZmRVFSfOMmM1mBoBZLBZ+TxRF1tDQwERR5M8URUl732KxMJfLxd+RZZk5nU4my3Ja/U6nkxmNRgaA/zf1crlczGAw8PdT60wt7/V6+c92u505nc5h443UK3VcqeMbzbvQeK6sVquuty51fOFwmN/Xa0cQhLTxCYJwUblwOMxKS0uZ0Wi86ITgSGudrdBcZAe0zunQfGQHFOM0Aaxbtw6bNm3SfXbu3DkAwNDQEL83MDCA1atXY2BggD/bvn07nE4nLzM0NISuri5YrVYMDg6ip6dHt341zsdkMkEURfT19QEALBYLzp07B8YYGGNp76t1JpNJGI1GMMZQWlqK9vZ2MMbQ19fHVb9V75KKIAhIJBJp/UzFYrHwcTmdTlx//fU4cOBAxv6z/1MSTyaTEEURg4ODsFgsWLVqFXbv3o3W1lacOHECAJBMJtHW1oZIJJJ2X8VgMKT1zWaz4ZZbbkFrayv27dvH77e1taGzsxOiKOLs2bPIyckh9XCCIAhi3KGNUwbq6uoQj8cRj8cRDofx5ptvQpIkdHV1AQBKSkoAAEeOHEEikYAoiohGo9i9ezfeeecdHD58GGazGU1NTbjvvvtw7NgxHDx4EDNnzsRtt92Gn/70p7y+RCIBQRB4ncePH0dRUREkSUJvby9OnDgBv9+PFStWoKmpCYqiYOPGjbwdURRRWFiIrq4unD9/ngdfHzhwAEVFRejq6oLL5UJ3dzcGBwdhtVoxd+5cBAIBbNiwgac3Sa23ra0NyWQSBoMB4XAYb7zxBvr7+5GXlwdZlvHoo4/iJz/5Cfbu3YvBwUEUFhais7MTQ0NDmDZtGk8cHA6HsX79eh4AHw6H0dLSwqUakskkVqxYgYceegiNjY144IEHeIC9LMvo7+9HVVUVnn/+eRgMBnzqU5/Czp07ubCoOvcrVqzAwYMHoSgKmpqaUFdXR0HhBEEQxLhDG6cMLFiwAE6nE5s2bcJbb72Frq4u/MM//AMAYNOmTQgGgwCAffv2gTGG/v5+HDx4EC+88ALKysqQSCSQSCRw9uxZrF+/Hh/72Mfw1ltvIRgM4uDBg2n1PfXUUwDA6zxw4ADsdjv3qNx+++3YsGEDAOCOO+7g/62trUUsFkNvby96e3u5BlJ/fz8fR09PD0pLS3HkyBHuZUokEjh+/DiOHz+O/v5+bN++He+99x6vV1UkBy54j15++WWcP38eAPDuu+8iFovB6XSivLwczc3NAC54rc6dO8f70NLSgqVLl+LgwYM4duwY1q9fj2PHjsFisfD+lZaW8g0lADQ1NenOS05ODpYuXYpNmzZh+/btaeMDwOdeO0cEQRAEMd7QxmkY6urq0NbWhoMHD6K0tBQ7d+7EkiVL4Ha7EYvFcM899yAej+P48ePo6uqC3+9HbW0t5s+fjyNHjgAAcnNzUV9fjzlz5gAA9+pUVlZyWYOqqioYDAbE43HU1NQAACRJwvr162EwGCBJEqqrqwGAe3Dq6+vh9/t5kLYqWaB6ltSku6IoclHMvr4+HDp0CIODgxAEAcFgEEePHsWJEye4fIGiKLpfl4miiEQigdLSUlRUVPBxVFVVcc/SunXr0N/fD0EQ+Pg6OzshCALmz5+PoaEh5OTkoKurC4IgIBKJYPv27VxQNB6Po7Kyks9DPB7H6dOn+ebQ7XajqqoKv//97/mYk8kkEokEmpubEQwGEY/H8cgjj9DmiSAIgrgyjHvU1BRkuCAyvaP3esl+9VKNQCdAWZsuJJM0gfZ4vVYCobS0NGOqEm0ak0wJfbUCnZnuaetJHUcmCQK91C9awU+thIOeeGWqhIGeMKbepRcUPpq1zjZGOxepByWGuzdaMr071vujeX4lno2lzFjfGWudoylPNp8O2T3Z/eVCGyeWeYL37NnDQqEQk2WZiaLITCYTMxqNzGazMafTyZYvX85KS0vZ8uXLmdfrZT6fj8myzCRJYpIkMVmWWSgUYmvWrGEej4cFg0EWCASY0+lkgUCAhUIhfvKuoaGBn7BTy8myzGw2W9rJuE9+8pPMaDQyn8/Hbr31VmYwGJjVamWSJDGbzcYsFgu79dZb0+oKh8NMURS2Zs0a5nQ6mdVq5ZuQ1A2HyWRiH/3oR3VPxxUXF/N69uzZw08L+nw+JooiW758OfP5fMxgMLDp06ezUCjEgsEgk2WZybLMAoEAL3vrrbey0tJStmbNGqYoSlrfJEliLpeLz+2aNWtYMBhkJpOJmc1mtnz5chYKhZjT6Uybb5vNxjdmn/zkJ/lpw4aGhlGtdTYy2rm4VKXjsdR3KfdH8/xKPBtLmbG+M9Y6R1OebD4dsnuy+8uFNk5s5JQrel4YbRJarRdEFagc7n3tH6Tec+272qTAeh6XTAl7VW+Oniin9l09L85wXqacnJy0NCx6qWr0kvam9k1vnFrPWmo/UuvPNAat94n+EfkQ+uRNn7yzEbJ7svvLxcBYyrn0LOX06dOQZRk9PT3Izc3l91taWnD//fdj//79AAC73Y7Ozk643W4MDAwgLy8PHR0dOHv2LDweD7q7u3H27FkYjUbMmjUL4XAYv/rVr+BwOPDBBx8AADweD5cFmD17Nh577DEsWLAALS0t+Od//ue0FCOMMZ6mBLhwFN9ms6GrqwsWiwUGg4HLE9hsNh4/ZDabkZubix/+8IeYM2cO7rvvPhw+fBj/8i//goMHD6K/vx/PPfcc5s2bh7/97W9wuVzo7e3lAprPP/88kskkLBYLGGMYHByE0WjEddddh2nTpiE3N5fHNJ0+fZoLZy5atAjNzc0oLi7GtGnTcPLkSX46z2azwW63czmG+fPn49FHHwUAfPOb30RPTw96e3tx9OhRCIKAL37xi3j++edRWlqKYDDI08a43W74fD709fXxODK73Y6Ojg4uFPqFL3wBW7ZsgcFgwA9/+MO0eKdMa52N0FxkB7TO6dB8ZAdXdJ3HfSs2BRlNjFOq50KNu0lNqaL11GhTsiDFk5Ias5PqddJLkDtcwl29uqHxtGjrUe8N56rVS9qbWm8mL5rJZEqLYxqur9r51HqghvNGZZrb4WKbRrPW2QbNRXZA65wOzUd2QAKYE0RLSwscDgcsFgsEQUA4HEZvb29aoty///u/x/bt21FbW4udO3dyj1FBQQGOHDkCm82GoaEhCIKAZDIJr9cLt9uNvr4+fhKvurqaJwb2er2IRqPYvHkzTCYTJEmCJEnYsmULcnNzdb1eXV1dGBoa4hpNyWQSgiCguroaJSUl/PTZrl27EA6H+Uk3SZJQW1ubpn2k6lcdP34cTU1NPCXLwMAAjEYjIpEI9u3bh9dff53Pk8PhwNDQEFwuFwYGBuD3+6EoSppHqri4GKIocg9UR0cH5s6di6KiIoTDYRQXF0NRFMyZM4frZ9lsNiSTSfT09MBkMnE5BavVikQiAYPBgKKiIuTn56O9vR1er5eS+hIEQRBXlnHfik1BhotxGsnLk8ljpD2NhxRvidabovVAaeOdMiUTTu2n+lz1+mAYz0xqvXon2VLHoo5Rb2yp9WTykGk9Xdo+pJ6y03rEtHOnF3+ljeMaKbiQPm1+CM1FdkDrnA7NR3ZASX5HweOPP46ysjKIooiqqiq8+uqrl1VfY2MjT9YbCoXg8/lgsVh40l01aW8sFkMwGITL5UJ/fz/3ykQiERiNRjgcDgiCAKvVCpPJhNzcXPj9fsRiMbjdbkQiERQWFsLn88Hj8UCSJDQ1NcFqtcJgMMDn8yESiQC4oKdUVlaG4uJiSJIEr9eLuXPnIhaLwefzwWq1wuPxoKCgAMFgEKFQCF6vFyaTiSfvDQaDPKlufn4+11NSkw3X1taisbERXq8XTqcT4XAYiqKked4cDgdkWYYoijxdy29+8xs+N1u2bMG2bdtQXl4Oj8cDRVEAfJgs2Wg08v6EQiE4HA643W4oioLGxkbu6Zo3bx5fD4PBgHnz5sFqtcLhcPB73d3d+MpXvsLfUbW0WlpaLmv9CYIgCEKXcd+KTQBPP/00s1gs7IknnmB/+9vf2PLly1leXh7r6OgY1ft6O1NtbJP2xNhwcUeZNJZSn2fSNNJ6iLR1mUymi8pl8vIMF9+kPammfZZJSym1vdR5Ue+n9j11rrT1ZOpjan16JwpH8kTpec9GWutsheYiO6B1TofmIzsgj9MI/OhHP8Ly5csRjUYxZ84c/PSnP0VOTg6eeOKJS66zvr4eLpcLXV1d3DMkyzIGBwcxe/ZsHg9UVVUFSZJgMplgtVphsVgQjUZRX18Pp9PJc6oVFxdDEATIsoxoNIqqqirk5uaio6MDjDF0dHQgLy8PtbW18Hg8cLvd3HtVX18Pi8UC4ELCXaPRCKPxwtKZTCYIgoC9e/dCURQEAgFdr4/qiVEUBUuXLkV9fT0CgQBEUUQkEkE4HEZTUxNmz54Nh8MBSZKwbds2LFy4EIqiIBKJIBQKIRgMcs9YWVlZWhJjWZYhCAJEUYTL5UJtbS2PXQLA50uWZeTm5iIWi0FRFJSXl3OF9Pr6ekiSBIPBgFmzZnGFcFmWeX1LliyBxWKByWQCAMybNw8ejwfhcBjRaJQru5PXiSAIghh3xn0rdpUZHBxkJpOJPfPMM2n377jjDvb5z39e952BgQHW09PDr6NHj+ruTLXeD6vVygAwo9HI9SMURbnIo2Q0Grm4o3rPbDZzT4uiKMzpdOp6o4xGIwsGg8zr9fJ29+zZw0RRHDbWCgBTFIW5XK6LvDRGo5EZjUYGgIXDYcbYBR0MtY3UutV+pl6q96ahoYGXVevTXur41DGqv6uCmbIsp5X3+XwXjT+1LgBs+vTpLCcnhwtsOp3OtPlT57u0tJSFw+GLvGyp0KfND7laczEWjZbL1ZiZrIyXTs2lzAHZfDpk91ePa9Xup/zG6dixYwwA+8tf/pJ2/9///d/ZTTfdpPvOQw89pPuPvnaCVTVvi8XCjEZj2j/66j/KehsndcOi98zr9WZ8J/UKhUJ801NTU8MCgcCwGxZ145S6oXC5XGzNmjVpX3l5PB7G2MWB2uqGSfs1mNPp5MaqJwug/ixJUtr4Up8pipLxa83hxjOWsuoYPR4P37jp/ZHRPyIfcrXmYiyqwJerajxZGS9l5EuZA7L5dMjurx7Xqt1npRzBN77xDaxcuZL/fvr0acyYMeOicnfccQfuuOMOtLS0YN26dTyxLXDhaycAWLt2Lf9KrrGxEbm5uThz5gxWrVqFz372s+jp6cGhQ4dQVFSEoqIirF27FgDS7qtf55WUlKCxsRF+vx+PPfYYAGDdunW8LbUPP/nJT3DgwAEAQElJCX9/7dq12L9/P1avXo36+nou/Hjddddh5cqVMBgM+N73vsf7H4/H0dPTA1mWUVNTg6amJvj9fqxfvx7RaBQHDx5EXV0dP95fX1+PBx54AG63m8sIqAmH58yZk9ZXdYxqvwAgHo/j2LFjiMfj+NznPoft27cjEongueeegyRJaG9vx9mzZwFckDhYunQptm/fjvnz53PJhyNHjqCnpydtndQkw2pfKMHv5EK1iVTbuJSyY6lnsjHWvmcqP5XnINsgu7927X7KK4cPDQ0hJycHmzdvxq233srv33nnneju7sbWrVtHrIOUZLMHWusPobnIDmid06H5yA6u5DpP+eBwi8WCG2+8ETt27OD3kskkduzYgXA4PIE9IwiCIAjiWuOa+Kpu5cqVuPPOO7FgwQLcdNNN+PGPf4y+vj5Eo9GJ7hpBEARBENcQ18TG6Z/+6Z/Q2dmJ1atXo729HTfccANeeOEFFBYWTnTXCIIgCIK4hrgmNk4A8NWvfhVf/epXJ7obBEEQBEFcw0z5GCeCIAiCIIirBW2cCIIgCIIgRsk181Xd5aAqMpw+fXqCe0JcadQ1nuIqHOMC2X12QDafDtl9dnAl7Z42TgDOnDkDALoimMS1yZkzZyDL8kR3Y0Ihu88uyOYvQHafXVwJu5/yApjjQTKZxPHjx+FwOGAwGAB8qCZ+9OhREkmbgmRaP8YYzpw5g6KiIp4oOVvR2j3Z/NRHbw3J5tMhu7/2uNp2Tx4nAEajEdOnT9d9lpubS39MUxi99aNP3RfIZPdk81Mf7RqSzX8I2f21y9Wye/r4QRAEQRAEMUpo40QQBEEQBDFKaOOUAavVioceeghWq3Wiu0JcArR+Y4fmbOpDazh2aM6mPld7DSk4nCAIgiAIYpSQx4kgCIIgCGKU0MaJIAiCIAhilNDGiSAIgiAIYpTQxikDjz/+OMrKyiCKIqqqqvDqq69OdJeyjj/96U9YsmQJioqKYDAY8Oyzz6Y9Z4xh9erV8Hq9sNlsuPnmm3Ho0KG0Mh988AFqa2uRm5uLvLw83H333ejt7U0r09raio9//OMQRREzZszA97///Ss9tEkH2fvkgez+6kF2P3mYUnbPiIt4+umnmcViYU888QT729/+xpYvX87y8vJYR0fHRHctq/j973/P/t//+39sy5YtDAB75pln0p4/8sgjTJZl9uyzz7I333yTff7zn2fXXXcdO3v2LC9TXV3N5s2bx1555RX2v//7v6yiooLdfvvt/HlPTw8rLCxktbW1bN++feypp55iNpuN/exnP7taw5xwyN4nF2T3Vwey+8nFVLJ72jjpcNNNN7F7772X/37+/HlWVFTEvvvd705gr7Ib7R9SMplkHo+H/eAHP+D3uru7mdVqZU899RRjjLH9+/czAGzPnj28zP/8z/8wg8HAjh07xhhj7D//8z9Zfn4+Gxwc5GUeeOABNnPmzCs8oskD2fvkhez+ykF2P3mZ7HZPX9VpGBoawmuvvYabb76Z3zMajbj55puxe/fuCewZkUpbWxva29vT1kmWZVRVVfF12r17N/Ly8rBgwQJe5uabb4bRaERzczMv84lPfAIWi4WXURQFb7/9Nk6dOnWVRjNxkL1PLcjuxwey+6nFZLN72jhp6Orqwvnz51FYWJh2v7CwEO3t7RPUK0KLuhbDrVN7ezumTZuW9lwQBBQUFKSV0asjtY1rGbL3qQXZ/fhAdj+1mGx2TxsngiAIgiCIUUIbJw0ulwsmkwkdHR1p9zs6OuDxeCaoV4QWdS2GWyePx4OTJ0+mPU8kEvjggw/SyujVkdrGtQzZ+9SC7H58ILufWkw2u6eNkwaLxYIbb7wRO3bs4PeSySR27NiBcDg8gT0jUrnuuuvg8XjS1un06dNobm7m6xQOh9Hd3Y3XXnuNl9m5cyeSySSqqqp4mT/96U84d+4cL/Piiy9i5syZyM/Pv0qjmTjI3qcWZPfjA9n91GLS2f2lRLxf6zz99NPMarWyJ598ku3fv5995StfYXl5eay9vX2iu5ZVnDlzhr3++uvs9ddfZwDYj370I/b666+zw4cPM8YuHE/Ny8tjW7duZa2treyWW27RPZ46f/581tzczF5++WV2/fXXpx1P7e7uZoWFhexLX/oS27dvH3v66adZTk5O1h3LJnufPJDdXx3I7icXU8nuaeOUgccee4yVlJQwi8XCbrrpJvbKK69MdJeyjpdeeokBuOi68847GWMXjqh+61vfYoWFhcxqtbLFixezt99+O62OeDzObr/9dma321lubi6LRqPszJkzaWXefPNNtmjRIma1WllxcTF75JFHrtYQJw1k75MHsvurB9n95GEq2b2BMcbG5DMjCIIgCILIUijGiSAIgiAIYpTQxokgCIIgCGKU0MaJIAiCIAhilNDGiSAIgiAIYpTQxokgCIIgCGKU0MaJIAiCIAhilNDGiSAIgiAIYpTQxokgCIIgCGKU0MZpCmEwGPDss89OdDcI4qpCdk9kI2T3kxfaOE0SOjs7cc8996CkpARWqxUejweKouDPf/4zL3PixAl8+tOfvmp9eu+992AwGPDGG29ctTaJ7ILsnshGyO6nNsJEd4C4wG233YahoSE0NDSgvLwcHR0d2LFjB+LxOC/j8XgmsIcEMf6Q3RPZCNn9FOcS8/ER48ipU6cYAPbHP/5x2HIA2DPPPMMYY6ytrY0BYP/93//NFi1axERRZAsWLGBvv/02e/XVV9mNN97IJEli1dXV7OTJkxnr/OCDD1hNTQ1zuVxMFEVWUVHBnnjiCd5e6vXJT36Sv/eLX/yCzZo1i1mtVjZz5kz2+OOP82dq35566ikWDoeZ1WplH/nIR0YcH5FdkN0T2QjZ/dSHNk6TgHPnzjG73c6+9rWvsYGBgYzl9P6QZs2axV544QW2f/9+FgqF2I033sj+7u/+jr388svsr3/9K6uoqGArVqzIWOe9997LbrjhBrZnzx7W1tbGXnzxRfa73/2OMcbYq6++ygCwP/zhD+zEiRMsHo8zxhj79a9/zbxeL/vtb3/LYrEY++1vf8sKCgrYk08+mda36dOns82bN7P9+/ezL3/5y8zhcLCurq5xmjViqkN2T2QjZPdTH9o4TRI2b97M8vPzmSiKbOHChewb3/gGe/PNN9PK6P0h/fKXv+TPn3rqKQaA7dixg9/77ne/y2bOnJmx3SVLlrBoNKr7TG3j9ddfT7vv8/nYxo0b0+59+9vfZuFwOO29Rx55hD8/d+4cmz59Ovve976XeRKIrIPsnshGyO6nNhQcPkm47bbbcPz4cfzud79DdXU1/vjHP+KjH/0onnzyyWHfmzt3Lv+5sLAQABAMBtPunTx5MuP799xzD55++mnccMMNWLVqFf7yl78M215fXx/effdd3H333bDb7fxau3Yt3n333bSy4XCY/ywIAhYsWIADBw4MWz+RXZDdE9kI2f3UhjZOkwhRFPGpT30K3/rWt/CXv/wFd911Fx566KFh3zGbzfxng8Ggey+ZTGZ8/9Of/jQOHz6Muro6HD9+HIsXL8bXv/71jOV7e3sBAL/4xS/wxhtv8Gvfvn145ZVXRjVOgkiF7J7IRsjupy60cZrEzJkzB319fVe8HbfbjTvvvBO//vWv8eMf/xg///nPAQAWiwUAcP78eV62sLAQRUVFiMViqKioSLuuu+66tHpT/7ASiQRee+01zJ49+4qPh5jakN0T2QjZ/dSB5AgmAfF4HP/4j/+IZcuWYe7cuXA4HGhpacH3v/993HLLLVe07dWrV+PGG2/ERz7yEQwODuL555/nxj5t2jTYbDa88MILmD59OkRRhCzLePjhh3H//fdDlmVUV1djcHAQLS0tOHXqFFauXMnrfvzxx3H99ddj9uzZWLduHU6dOoVly5Zd0fEQUweyeyIbIbu/BpjoICuCsYGBAfbggw+yj370o0yWZZaTk8NmzpzJvvnNb7L+/n5eDjrBgqmBfC+99BIDwE6dOsXvrV+/nsmynLHtb3/722z27NnMZrOxgoICdsstt7BYLMaf/+IXv2AzZsxgRqMx7Xjqhg0b2A033MAsFgvLz89nn/jEJ9iWLVvS+rZx40Z20003MYvFwubMmcN27tx5WfNEXFuQ3RPZCNn91MfAGGMTs2UjrlXee+89XHfddXj99ddxww03THR3COKqQHZPZCPZaPcU40QQBEEQBDFKaONEEARBEAQxSuirOoIgCIIgiFFCHieCIAiCIIhRQhsngiAIgiCIUUIbJ4IgCIIgiFFCGyeCIAiCIIhRQhsngiAIgiCIUUIbJ4IgCIIgiFFCGyeCIAiCIIhRQhsngiAIgiCIUUIbJ4IgCIIgiFHy/wGqJTc6JbNPQQAAAABJRU5ErkJggg==",
111 | "text/plain": [
112 | ""
113 | ]
114 | },
115 | "metadata": {},
116 | "output_type": "display_data"
117 | }
118 | ],
119 | "source": [
120 | "# Little util function\n",
121 | "def spike_tensor_to_points(spike_tensor):\n",
122 | " x = np.array([p[1].item() for p in torch.nonzero(spike_tensor.cpu())])\n",
123 | " y = np.array([p[0].item() for p in torch.nonzero(spike_tensor.cpu())])\n",
124 | " \n",
125 | " return x, y\n",
126 | "\n",
127 | "fig, axs = plt.subplots(1, 3, figsize=(6, 3))\n",
128 | "axs[0].scatter(*spike_tensor_to_points(input_raster), color=\"black\", s=0.5)\n",
129 | "axs[1].scatter(*spike_tensor_to_points(blocks_spikes[0]), color=\"black\", s=0.5)\n",
130 | "axs[2].scatter(*spike_tensor_to_points(standard_spikes[0]), color=\"black\", s=0.5)\n",
131 | "axs[0].set_title(\"Input raster\")\n",
132 | "axs[1].set_title(\"Blocks output\")\n",
133 | "axs[2].set_title(\"Standard output\")\n",
134 | "axs[0].set_ylabel(\"NeuronID\")\n",
135 | "axs[0].set_xlabel(\"Sim step\")\n",
136 | "axs[1].set_xlabel(\"Sim step\")\n",
137 | "axs[2].set_xlabel(\"Sim step\")\n",
138 | "fig.tight_layout()"
139 | ]
140 | },
141 | {
142 | "cell_type": "markdown",
143 | "id": "7def7ba2",
144 | "metadata": {},
145 | "source": [
146 | "## Model speed"
147 | ]
148 | },
149 | {
150 | "cell_type": "markdown",
151 | "id": "24c34073",
152 | "metadata": {},
153 | "source": [
154 | "How much faster is our blocks models model?"
155 | ]
156 | },
157 | {
158 | "cell_type": "code",
159 | "execution_count": 108,
160 | "id": "fb472914",
161 | "metadata": {},
162 | "outputs": [],
163 | "source": [
164 | "def get_training_duration(model, device=\"cpu\"):\n",
165 | " model = model.to(device)\n",
166 | " data = input_raster.unsqueeze(0).to(device).repeat(128, 1, 1) # 128 batch size\n",
167 | " torch.cuda.synchronize()\n",
168 | " start_time = time.time()\n",
169 | " output = model(data)\n",
170 | " loss = output.sum() # Arbitraty loss just so we have something to backpropogate\n",
171 | " loss.backward()\n",
172 | " torch.cuda.synchronize()\n",
173 | " training_duration = time.time() - start_time\n",
174 | " return training_duration"
175 | ]
176 | },
177 | {
178 | "cell_type": "code",
179 | "execution_count": 103,
180 | "id": "91db4382",
181 | "metadata": {},
182 | "outputs": [],
183 | "source": [
184 | "def get_training_durations_for_both_models(device):\n",
185 | " standard_training_times = [get_training_duration(standard_snn, device) for _ in range(11)][1:]\n",
186 | " block_training_times = [get_training_duration(blocks_snn, device) for _ in range(11)][1:]\n",
187 | " return pd.DataFrame({\"standard\": standard_training_times, \"block\": block_training_times})"
188 | ]
189 | },
190 | {
191 | "cell_type": "code",
192 | "execution_count": 104,
193 | "id": "b45b4bdf",
194 | "metadata": {},
195 | "outputs": [],
196 | "source": [
197 | "torch.backends.cudnn.benchmark = True # Make sure we use the best conv algorithm"
198 | ]
199 | },
200 | {
201 | "cell_type": "code",
202 | "execution_count": 107,
203 | "id": "204b6a24",
204 | "metadata": {},
205 | "outputs": [
206 | {
207 | "data": {
208 | "image/png": "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I0uBmweNKUrNMAxduXLF02UAQLlGlgJXR2NaBlatWqWIiAhVrlxZNWrUUExMjEaNGqU333xTf//732Wz2WSz2bR9+3ZJ0ujRo9WsWTNVqVJFjRo10oQJE3T58mXn+l566SW1b99ef/3rXxUeHq6goCA9/PDDunDhgnPOxYsXFRsbK39/f9WpU0czZ84sVtdf//pXRUZGKiAgQLVr19aAAQN0+vRp5+fbt2+XzWbTpk2b1LFjR9ntdu3cubNU60bZu3LlioYPH66goCCFhIRowoQJutrT4NLS0nTvvffK399fgYGB6tevX7GvtVy/fr06deokPz8/hYSE6P7777/qthcvXqxq1aoV+wYiwExkKdyNHPU8GlM3O3XqlB555BE99thjOnLkiLZv364HHnhAkyZNUr9+/XT33Xfr1KlTOnXqlLp27SpJCggI0LJly3T48GHNmTNHixYt0uzZs4us99ixY1q7dq02bNigDRs2aMeOHZo2bZrz81GjRmnHjh36+9//rq1bt2r79u3at29fkXVcvnxZU6ZM0YEDB7R27VqdOHFCjz76aLF9GDNmjKZNm6YjR46obdu2pVo3yt6bb74pHx8f7d69W3PmzNGsWbO0ePHiYvMKCwt177336uzZs9qxY4e2bdum48ePq3///s45H3zwge6//3716tVL+/fvV0pKijp37lzidmfMmKExY8Zo69atuvPOO922f6jYyFKUBXLUAgy4VWpqqiHJOHHiRLHP4uLijHvvvfea63j11VeNjh07Ot9PmjTJqFKlipGbm+scGzVqlBEVFWUYhmFcuHDB8PX1Nd577z3n599//71RuXJlY8SIEVfdzp49ewxJxoULFwzDMIyPP/7YkGSsXbvWOed61w33io6ONlq2bGkUFhY6x0aPHm20bNnSMAzDaNCggTF79mzDMAxj69athre3t5GWluac++WXXxqSjN27dxuGYRhdunQxBg4ceNXt/by+P/7xj0adOnWMQ4cOuWGvgP8iS+Fu5Kg1cMTUzdq1a6c777xTEREReuihh7Ro0SKdO3fuV5dZuXKlunXrptq1a8vf31/jx49XWlpakTnh4eEKCAhwvq9Tp47z1NGxY8dUUFCgqKgo5+fVq1dX8+bNi6wjNTVVffr0Uf369RUQEKDo6GhJKratyMhI58+lXTfK3m233SabzeZ836VLF33zzTdyOBxF5h05ckRhYWEKCwtzjrVq1UrVqlXTkSNHJElffPHFNf9qnzlzphYtWqSdO3eqdevWJu4JUBxZirJAjnoejambeXt7a9u2bdq0aZNatWqlefPmqXnz5vruu+9KnL9r1y4NHDhQvXr10oYNG7R//36NGzdOBQUFReZVqlSpyHubzabCwsJS13Xx4kX16NFDgYGBevvtt7Vnzx6tWbNGkoptq2rVqqVeL24OlStXvuac22+/XQ6HQ++9914ZVISKjixFeUOOXh8a0zJgs9nUrVs3JSYmav/+/fL19dWaNWvk6+tb7K+wzz77TA0aNNC4ceMUGRmppk2b6uTJky5tr3HjxqpUqZI+//xz59i5c+f09ddfO98fPXpU33//vaZNm6bbb79dLVq0KHKx/o2sG57xy/8mkvTPf/5TTZs2lbe3d5Hxli1bKj09Xenp6c6xw4cP6/z582rVqpUkqW3btte8AL9z587atGmTpk6dqj/96U8m7QVwdWQp3I0c9TwfTxdws/v888+VkpKi7t27q1atWvr888+VnZ2tli1b6scff9SWLVv01VdfqUaNGgoKClLTpk2VlpamFStWqFOnTvrggw+cf32Xlr+/vx5//HGNGjVKNWrUUK1atTRu3Dh5ef3375D69evL19dX8+bN09ChQ3Xo0CFNmTLFlHXDM9LS0hQfH6+nnnpK+/bt07x580q8yzcmJkYREREaOHCgkpOTdeXKFT3zzDOKjo52nmqcNGmS7rzzTjVu3FgPP/ywrly5oo0bN2r06NFF1tW1a1dt3LhRPXv2lI+PDw8Gh9uQpSgL5KgFePoi15vd4cOHjR49ehg1a9Y07Ha70axZM2PevHmGYRjG6dOnjbvuusvw9/c3JBkff/yxYRg/XXxfo0YNw9/f3+jfv78xe/ZsIygoyLnOSZMmGe3atSuyndmzZxsNGjRwvr9w4YLxhz/8wahSpYoRGhpqzJgxw4iOji5yUf0777xjhIeHG3a73ejSpYuxbt06Q5Kxf/9+wzD+e8H+uXPnimyrNOtG2YqOjjaeeeYZY+jQoUZgYKARHBxsjB071nkR/y8v2jcMwzh58qTRt29fo2rVqkZAQIDx0EMPGZmZmUXW+be//c1o37694evra4SEhBgPPPCA87P/Xd+OHTuMqlWrGnPnznXrfqLiIkvhbuSoNdgM4yoP6AIAAADKEOcMAAAAYAk0pgAAALAEGlMAAABYAo0pAAAALIHGFAAAAJZAYwoAAABLoDEFAACAJdCYAgAAwBJoTAEAAGAJNKYAAACwBBpTAAAAWAKNKQAAACzh/wF+f/hj5kkauQAAAABJRU5ErkJggg==",
209 | "text/plain": [
210 | ""
211 | ]
212 | },
213 | "metadata": {},
214 | "output_type": "display_data"
215 | }
216 | ],
217 | "source": [
218 | "fig, axs = plt.subplots(1, 2, figsize=(8, 3), sharey=False)\n",
219 | "\n",
220 | "def plot_training_durations(ax, device):\n",
221 | " sns.barplot(get_training_durations_for_both_models(device), ax=ax)\n",
222 | " ax.set(ylabel=\"Training time (sec)\", title=device)\n",
223 | " sns.despine()\n",
224 | " \n",
225 | "plot_training_durations(axs[0], \"cpu\")\n",
226 | "plot_training_durations(axs[1], \"cuda\")"
227 | ]
228 | },
229 | {
230 | "cell_type": "markdown",
231 | "id": "f9e54af9",
232 | "metadata": {},
233 | "source": [
234 | "Voila! A condsiderable reduction in training time, on both CPUs and GPUs!"
235 | ]
236 | }
237 | ],
238 | "metadata": {
239 | "kernelspec": {
240 | "display_name": "Python [conda env:blocks] *",
241 | "language": "python",
242 | "name": "conda-env-blocks-py"
243 | },
244 | "language_info": {
245 | "codemirror_mode": {
246 | "name": "ipython",
247 | "version": 3
248 | },
249 | "file_extension": ".py",
250 | "mimetype": "text/x-python",
251 | "name": "python",
252 | "nbconvert_exporter": "python",
253 | "pygments_lexer": "ipython3",
254 | "version": "3.8.16"
255 | }
256 | },
257 | "nbformat": 4,
258 | "nbformat_minor": 5
259 | }
260 |
--------------------------------------------------------------------------------
/notebooks/library_comparison.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "code",
5 | "execution_count": 53,
6 | "id": "bef22c5e",
7 | "metadata": {},
8 | "outputs": [],
9 | "source": [
10 | "import time\n",
11 | "\n",
12 | "import torch\n",
13 | "import torch.nn as nn\n",
14 | "from spikingjelly.activation_based import neuron, layer, surrogate\n",
15 | "from norse.torch.module.lif import LIFCell\n",
16 | "\n",
17 | "from src.snn.block.blocks import Blocks\n",
18 | "from src.snn.snn import SNN"
19 | ]
20 | },
21 | {
22 | "cell_type": "markdown",
23 | "id": "f8051e64",
24 | "metadata": {},
25 | "source": [
26 | "## Setting up the different implementations"
27 | ]
28 | },
29 | {
30 | "cell_type": "markdown",
31 | "id": "4ca0b48b",
32 | "metadata": {},
33 | "source": [
34 | "Network benchmarked: 200 input units -> 100 spiking units over 1000 simulation steps using a batch size of 128."
35 | ]
36 | },
37 | {
38 | "cell_type": "code",
39 | "execution_count": 93,
40 | "id": "38193107",
41 | "metadata": {},
42 | "outputs": [],
43 | "source": [
44 | "def time_jelly():\n",
45 | " input_tensor = torch.rand(128, 200, 1000).cuda()\n",
46 | " \n",
47 | " jelly_layer = nn.Sequential(\n",
48 | " layer.Linear(200, 100, bias=False),\n",
49 | " neuron.LIFNode(tau=100.0, surrogate_function=surrogate.ATan())\n",
50 | " ).cuda()\n",
51 | " \n",
52 | " start_time = time.time()\n",
53 | " \n",
54 | " for t in range(1000):\n",
55 | " out = jelly_layer(input_tensor[:, :, t])\n",
56 | " \n",
57 | " end_time = time.time()\n",
58 | " return end_time - start_time"
59 | ]
60 | },
61 | {
62 | "cell_type": "code",
63 | "execution_count": 94,
64 | "id": "976ed321",
65 | "metadata": {},
66 | "outputs": [],
67 | "source": [
68 | "def time_norse():\n",
69 | " input_tensor = torch.rand(128, 200, 1000).cuda()\n",
70 | " \n",
71 | " norse_layer = nn.Sequential(\n",
72 | " layer.Linear(200, 100, bias=False),\n",
73 | " LIFCell()\n",
74 | " ).cuda()\n",
75 | " \n",
76 | " start_time = time.time()\n",
77 | " \n",
78 | " for t in range(1000):\n",
79 | " out = norse_layer(input_tensor[:, :, t])\n",
80 | " \n",
81 | " end_time = time.time()\n",
82 | " \n",
83 | " return end_time - start_time"
84 | ]
85 | },
86 | {
87 | "cell_type": "code",
88 | "execution_count": 95,
89 | "id": "932cb5f0",
90 | "metadata": {},
91 | "outputs": [],
92 | "source": [
93 | "def time_blocks():\n",
94 | " input_tensor = torch.rand(128, 200, 1000).cuda()\n",
95 | " \n",
96 | " blocks_snn = Blocks(200, 100, 1, 1000, t_latency=50, recurrent=False, init_beta=0.99, init_p=0.99).cuda()\n",
97 | " start_time = time.time()\n",
98 | " out = blocks_snn(input_tensor)\n",
99 | " end_time = time.time()\n",
100 | " \n",
101 | " return end_time - start_time\n",
102 | " \n",
103 | "def time_standard():\n",
104 | " input_tensor = torch.rand(128, 200, 1000).cuda()\n",
105 | " \n",
106 | " blocks_snn = SNN(200, 100, 1, 1000, t_latency=1, recurrent=False, init_beta=0.99, init_p=0.99).cuda()\n",
107 | " start_time = time.time()\n",
108 | " out = blocks_snn(input_tensor)\n",
109 | " end_time = time.time()\n",
110 | " \n",
111 | " return end_time - start_time"
112 | ]
113 | },
114 | {
115 | "cell_type": "markdown",
116 | "id": "733c9e80",
117 | "metadata": {},
118 | "source": [
119 | "## Benchmarking the differnet implementations"
120 | ]
121 | },
122 | {
123 | "cell_type": "code",
124 | "execution_count": 97,
125 | "id": "0ecf6b39",
126 | "metadata": {},
127 | "outputs": [
128 | {
129 | "name": "stdout",
130 | "output_type": "stream",
131 | "text": [
132 | "Norse=0.3080105781555176\n",
133 | "Jelly=0.1869184970855713\n",
134 | "Standard=0.3317074775695801\n",
135 | "Blocks=0.016329288482666016\n"
136 | ]
137 | }
138 | ],
139 | "source": [
140 | "print(f\"Norse={time_norse()}\")\n",
141 | "print(f\"Jelly={time_jelly()}\")\n",
142 | "print(f\"Standard={time_standard()}\")\n",
143 | "print(f\"Blocks={time_blocks()}\")"
144 | ]
145 | }
146 | ],
147 | "metadata": {
148 | "kernelspec": {
149 | "display_name": "Python [conda env:blocks] *",
150 | "language": "python",
151 | "name": "conda-env-blocks-py"
152 | },
153 | "language_info": {
154 | "codemirror_mode": {
155 | "name": "ipython",
156 | "version": 3
157 | },
158 | "file_extension": ".py",
159 | "mimetype": "text/x-python",
160 | "name": "python",
161 | "nbconvert_exporter": "python",
162 | "pygments_lexer": "ipython3",
163 | "version": "3.8.16"
164 | }
165 | },
166 | "nbformat": 4,
167 | "nbformat_minor": 5
168 | }
169 |
--------------------------------------------------------------------------------
/scripts/ephys/build_data.py:
--------------------------------------------------------------------------------
1 | import argparse
2 |
3 | from src import datasets
4 |
5 |
6 | def main():
7 | parser = argparse.ArgumentParser()
8 | parser.add_argument("--root", type=str, default=".")
9 | args = parser.parse_args()
10 |
11 | # Builds current and spike tensors with DT=0.1ms
12 | # Note: You might want to set manifest_file if you have already downloaded the data from the Allen Institute
13 | builder = datasets.NoiseBuilder()
14 | builder.build(f"{args.root}/Blocks/data/ephys/train", noise_type="noise1")
15 | builder.build(f"{args.root}/Blocks/data/ephys/test", noise_type="noise2")
16 |
17 | # Builds current tensors with DT=0.05ms
18 | builder = datasets.NoiseBuilder()
19 | builder.build(f"{args.root}/Blocks/data/ephys/train", noise_type="noise1", target_sampling_rate=20000)
20 | builder.build(f"{args.root}/Blocks/data/ephys/test", noise_type="noise2", target_sampling_rate=20000)
21 |
22 |
23 | if __name__ == "__main__":
24 | main()
25 |
--------------------------------------------------------------------------------
/scripts/ephys/run.py:
--------------------------------------------------------------------------------
1 | import os
2 | root = "" # TODO: Change this to the project folder
3 |
4 | from src import datasets
5 |
6 |
7 | def launch(method, abs_refac_ms, downsample):
8 | neuron_idx_query = datasets.ValidNeuronQuery(f"{root}/data/ephys")
9 |
10 | dir_name = f"{method}_{abs_refac_ms}_{downsample}"
11 | os.makedirs(f"{root}/results/ephys/{dir_name}")
12 |
13 | for neuron_idx in neuron_idx_query.idx:
14 | os.system(f"python {root}/scripts/ephys/train.py --root={root} --method={method} --abs_refac_ms={abs_refac_ms} --downsample={downsample} --neuron_idx={neuron_idx} --dir_name={dir_name} --id={neuron_idx}")
15 |
16 |
17 | # Blocks: Different dt
18 | for downsample in [0.5, 1, 5, 10, 20, 40]:
19 | launch("blocks", abs_refac_ms=2, downsample=downsample)
20 |
21 | # Blocks: Different ARP
22 | for abs_refac_ms in [1, 4, 6, 8, 16]:
23 | launch("blocks", abs_refac_ms=abs_refac_ms, downsample=1)
24 |
25 | # Standard
26 | launch("standard", abs_refac_ms=2, downsample=1)
27 |
--------------------------------------------------------------------------------
/scripts/ephys/train.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import ast
3 | import logging
4 | import argparse
5 |
6 | from src import datasets, models, train
7 |
8 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
9 |
10 |
11 | def eval(v):
12 | return ast.literal_eval(v)
13 |
14 |
15 | def get_dataset(args):
16 | if args.downsample == 0.5:
17 | # Used dt=0.05ms for neural fits
18 | return datasets.EphysDataset(f"{args.root}/data/ephys", "train", args.neuron_idx, target_sampling_rate=20000)
19 | else:
20 | # Used dt>=0.1ms for neural fits (used by most experiments)
21 | return datasets.EphysDataset(f"{args.root}/data/ephys", "train", args.neuron_idx)
22 |
23 |
24 | def get_model(args):
25 | if args.downsample == 0.5:
26 | # Used dt=0.05ms for neural fits
27 | return models.Neuron(method=args.method, abs_refac_ms=args.abs_refac_ms, downsample=1, dt01ref=True)
28 | else:
29 | # Used dt>=0.1ms for neural fits (used by most experiments)
30 | return models.Neuron(method=args.method, abs_refac_ms=args.abs_refac_ms, downsample=int(args.downsample))
31 |
32 |
33 | def get_trainer(args, model, train_dataset):
34 | n_epochs = 200
35 | batch_size = 5
36 | lr = 0.0001
37 | dt = 0.1 * args.downsample
38 | return train.EphysTrainer(f"{args.root}/results/ephys/{args.dir_name}", model, train_dataset, n_epochs, batch_size, lr, gamma=0.1, dt=dt, epoch_scan=5, max_decay=0, val_dataset=None, device="cuda", id=args.id)
39 |
40 |
41 | def main():
42 | parser = argparse.ArgumentParser()
43 | parser.add_argument("--root", type=str, default=".")
44 |
45 | # Model
46 | parser.add_argument("--method", type=str, default="standard")
47 | parser.add_argument("--abs_refac_ms", type=int, default=10)
48 | parser.add_argument("--downsample", type=float, default=1)
49 |
50 | # Dataset
51 | parser.add_argument("--neuron_idx", type=int, default="")
52 |
53 | # Trainer
54 | parser.add_argument("--dir_name", type=str, default="")
55 | parser.add_argument("--id", type=str, default="")
56 |
57 | args = parser.parse_args()
58 |
59 | train_dataset = get_dataset(args)
60 | model = get_model(args)
61 | model_trainer = get_trainer(args, model, train_dataset)
62 | model_trainer.train(save=True)
63 |
64 |
65 | if __name__ == "__main__":
66 | main()
67 |
--------------------------------------------------------------------------------
/scripts/run_benchmarks.py:
--------------------------------------------------------------------------------
1 | import torch
2 | torch.backends.cudnn.benchmark = True # Important in order to use best conv algorithm
3 |
4 | from src.benchmark import Benchmarker
5 |
6 |
7 | def run_different_sim_lengths(root):
8 | n_in = 1000
9 | n_hidden = 128
10 |
11 | for abs_refac in [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]:
12 | for t_len in [2**9, 2**10, 2**11]:
13 | for batch_size in [32, 64, 128]:
14 | for method in ["standard", "blocks"]:
15 | bencher = Benchmarker(method, t_len, abs_refac, n_in, n_hidden, n_layers=1, batch_size=batch_size)
16 | bencher.benchmark()
17 | bencher.save(root)
18 |
19 |
20 | def run_different_layers(root):
21 | n_in = 1000
22 |
23 | for abs_refac in [40]:
24 | for t_len in [2**10]:
25 | for n_hidden in [128, 256, 512]:
26 | for batch_size in [64]:
27 | for n_layers in [2, 3, 4, 5]:
28 | for method in ["standard", "blocks"]:
29 | bencher = Benchmarker(method, t_len, abs_refac, n_in, n_hidden, n_layers=n_layers, batch_size=batch_size)
30 | bencher.benchmark()
31 | bencher.save(root)
32 |
33 |
34 | if __name__ == "__main__":
35 | root = "" # TODO: Change this to the project folder
36 | run_different_sim_lengths(f"{root}/benchmarks/sim_lengths")
37 | run_different_layers(f"{root}/benchmarks/layers")
38 |
39 |
--------------------------------------------------------------------------------
/scripts/supervised/run_blocks_nmnist.py:
--------------------------------------------------------------------------------
1 | import os
2 | root = "" # TODO: Change this to the project folder
3 |
4 |
5 | def launch(abs_refac, surr_grad, dt, method):
6 | for i in range(3):
7 | id = f"nmnist_{method}_{surr_grad}_{abs_refac}_{dt}_{i}"
8 | os.system(f"python {root}/scripts/supervised/train.py --root={root} --method={method} --abs_refac={abs_refac} --surr_grad={surr_grad} --name=nmnist --dt={dt} --id={id}")
9 |
10 |
11 | for abs_refac in [10, 20, 30, 40, 50]:
12 | launch(abs_refac, "mg", 1, "blocks")
13 |
--------------------------------------------------------------------------------
/scripts/supervised/run_blocks_shd.py:
--------------------------------------------------------------------------------
1 | import os
2 | root = "" # TODO: Change this to the project folder
3 |
4 |
5 | def launch(abs_refac, surr_grad, dt, method):
6 | for i in range(3):
7 | id = f"shd_{method}_{surr_grad}_{abs_refac}_{dt}_{i}"
8 | os.system(f"python {root}/scripts/supervised/train.py --root={root} --method={method} --abs_refac={abs_refac} --surr_grad={surr_grad} --name=shd --dt={dt} --id={id}")
9 |
10 |
11 | for abs_refac in [10, 20, 30, 40, 50]:
12 | launch(abs_refac, "mg", 1, "blocks")
13 |
--------------------------------------------------------------------------------
/scripts/supervised/run_detach_spikes.py:
--------------------------------------------------------------------------------
1 | import os
2 | root = "" # TODO: Change this to the project folder
3 |
4 |
5 | def launch(abs_refac, surr_grad, dt, detach_spike_grad):
6 | for i in range(3):
7 | id = f"shd_blocks_{surr_grad}_{abs_refac}_{dt}_{detach_spike_grad}_{i}"
8 | os.system(f"python {root}/scripts/supervised/train.py --root={root} --method=blocks --abs_refac={abs_refac} --surr_grad={surr_grad} --name=shd --dt={dt} --id={id} --detach_spike_grad={detach_spike_grad}")
9 |
10 |
11 | launch(30, "mg", dt=2, detach_spike_grad=False)
12 | launch(30, "fast_sigmoid", dt=2, detach_spike_grad=False)
13 | launch(30, "box_car", dt=2, detach_spike_grad=False)
14 |
--------------------------------------------------------------------------------
/scripts/supervised/run_standard_nmnist.py:
--------------------------------------------------------------------------------
1 | import os
2 | root = "" # TODO: Change this to the project folder
3 |
4 |
5 | def launch(abs_refac, surr_grad, dt, method):
6 | for i in range(3):
7 | id = f"nmnist_{method}_{surr_grad}_{abs_refac}_{dt}_{i}"
8 | os.system(f"python {root}/scripts/supervised/train.py --root={root} --method={method} --abs_refac={abs_refac} --surr_grad={surr_grad} --name=nmnist --dt={dt} --id={id}")
9 |
10 |
11 | launch(0, "mg", 1, "standard")
12 |
--------------------------------------------------------------------------------
/scripts/supervised/run_standard_shd.py:
--------------------------------------------------------------------------------
1 | import os
2 | root = "" # TODO: Change this to the project folder
3 |
4 |
5 | def launch(abs_refac, surr_grad, dt):
6 | for i in range(3):
7 | id = f"shd_standard_{surr_grad}_{abs_refac}_{dt}_{i}"
8 | os.system(f"python {root}/scripts/supervised/train.py --root={root} --method=standard --abs_refac={abs_refac} --surr_grad={surr_grad} --name=shd --dt={dt} --id={id}")
9 |
10 |
11 | launch(0, "mg", dt=2)
12 |
--------------------------------------------------------------------------------
/scripts/supervised/train.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import ast
3 | import logging
4 | import argparse
5 |
6 | from src import datasets, models, train
7 |
8 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
9 |
10 |
11 | def eval(v):
12 | return ast.literal_eval(v)
13 |
14 |
15 | def get_dataset(args, train=True):
16 | if args.name == "shd":
17 | return datasets.SHDDataset(f"{args.root}/data/SHD", train=train, dt=args.dt)
18 | elif args.name == "nmnist":
19 | return datasets.NMNISTDataset(f"{args.root}/data/N-MNIST", train=train, dt=args.dt)
20 |
21 |
22 | def get_model(args, dataset):
23 | abs_refac = int(args.abs_refac / args.dt)
24 |
25 | if args.name == "shd":
26 | n_in = 700
27 | n_out = 20
28 | elif args.name == "nmnist":
29 | n_in = 1156
30 | n_out = 10
31 |
32 | return models.AuditoryModel(args.method, n_in, args.n_hidden, n_out, dataset.t_len, abs_refac, eval(args.recurrent), args.dt, args.surr_grad, detach_spike_grad=eval(args.detach_spike_grad))
33 |
34 |
35 | def get_trainer(args, model, train_dataset, val_dataset):
36 | gamma = 0.1
37 | if args.name == "shd":
38 | milestones = [15, 15]
39 | epochs = 40
40 | elif args.name == "nmnist":
41 | milestones = [30]
42 | epochs = 20
43 | return train.Trainer(f"{args.root}/results", model, train_dataset, epochs, args.batch_size, args.lr, milestones, gamma, val_dataset, id=args.id)
44 |
45 |
46 | def main():
47 | parser = argparse.ArgumentParser()
48 | parser.add_argument("--root", type=str, default=".")
49 |
50 | # Model
51 | parser.add_argument("--method", type=str, default="standard")
52 | parser.add_argument("--n_hidden", type=int, default=256)
53 | parser.add_argument("--abs_refac", type=float, default=10)
54 | parser.add_argument("--recurrent", type=str, default="True")
55 | parser.add_argument("--detach_spike_grad", type=str, default="True")
56 | parser.add_argument("--surr_grad", type=str, default="fast_sigmoid")
57 |
58 | # Dataset
59 | parser.add_argument("--name", type=str, default="shd")
60 | parser.add_argument("--dt", type=float, default=1)
61 |
62 | # Trainer
63 | parser.add_argument("--batch_size", type=int, default=64)
64 | parser.add_argument("--lr", type=float, default=0.001)
65 | parser.add_argument('--id', type=str, default="")
66 |
67 | args = parser.parse_args()
68 |
69 | train_dataset = get_dataset(args, train=True)
70 | val_dataset = get_dataset(args, train=False)
71 | if args.name == "nmnist":
72 | val_dataset = None
73 | model = get_model(args, train_dataset)
74 | model_trainer = get_trainer(args, model, train_dataset, val_dataset)
75 | model_trainer.train(save=True)
76 |
77 |
78 | if __name__ == "__main__":
79 | main()
80 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import find_packages, setup
2 |
3 | setup(
4 | name="src",
5 | packages=find_packages(),
6 | )
7 |
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webstorms/Blocks/540c9f28eedd58ef638dcacf75b4c27cdf52baa0/src/__init__.py
--------------------------------------------------------------------------------
/src/benchmark.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 |
4 | import torch
5 | import pandas as pd
6 |
7 | from src import datasets
8 | from src import models
9 |
10 |
11 | class Benchmarker:
12 |
13 | def __init__(self, method, t_len, abs_refac, n_in, n_hidden, n_layers, batch_size=16, min_r=0, max_r=200, n_samples=11):
14 | self._method = method
15 | self._t_len = t_len
16 | self._abs_refac = abs_refac
17 | self._n_in = n_in
18 | self._n_hidden = n_hidden
19 | self._n_layers = n_layers
20 | self._batch_size = batch_size
21 | self._model = models.ModelBuilder(method, t_len, abs_refac, n_in, n_hidden, n_layers)
22 |
23 | self._data_loader = self._get_data_loader(t_len, n_in, min_r, max_r, batch_size, n_samples*batch_size)
24 | self._benchmark_results = None
25 |
26 | def benchmark(self, device="cuda"):
27 | timing_list = []
28 |
29 | self._model = self._model.to(device)
30 |
31 | for i, data in enumerate(self._data_loader):
32 | # Benchmark forward pass
33 | data = data.to(device)
34 |
35 | start_time = time.time()
36 | output = self._model(data)
37 | torch.cuda.synchronize()
38 | forward_pass_time = time.time() - start_time
39 |
40 | # Benchmark backward pass
41 | start_time = time.time()
42 | loss = output.sum()
43 | loss.backward()
44 | torch.cuda.synchronize()
45 | backward_pass_time = time.time() - start_time
46 |
47 | # Ignore first run (as this usually loads things which slows things down)
48 | # e.g. cudnn finds best conv algorithm
49 | if i > 0:
50 | timing_row = {"forward_time": forward_pass_time, "backward_time": backward_pass_time}
51 | timing_list.append(timing_row)
52 |
53 | self._benchmark_results = timing_list
54 |
55 | def save(self, path):
56 | results_df = self._to_df()
57 | results_df.to_csv(os.path.join(path, f"{self._get_df_name()}.csv"), index=False)
58 |
59 | def _get_description(self):
60 | return {"method": self._method, "t_len": self._t_len, "abs_refac": self._abs_refac, "units": self._n_hidden, "layers": self._n_layers, "batch": self._batch_size}
61 |
62 | def _get_df_name(self):
63 | return f"{self._method}_{self._t_len}_{self._abs_refac}_{self._n_hidden}_{self._n_layers}_{self._batch_size}"
64 |
65 | def _get_data_loader(self, t_len, n_units, min_r, max_r, batch_size, n_samples):
66 | spikes_dataset = datasets.SyntheticSpikes(t_len, n_units, min_r, max_r, n_samples)
67 | return torch.utils.data.DataLoader(spikes_dataset, batch_size, shuffle=False)
68 |
69 | def _to_df(self):
70 | assert self._benchmark_results is not None
71 | results = []
72 |
73 | for results_row in self._benchmark_results:
74 | results.append({**results_row, **self._get_description()})
75 |
76 | return pd.DataFrame(results)
77 |
--------------------------------------------------------------------------------
/src/datasets/__init__.py:
--------------------------------------------------------------------------------
1 | from .synthetic import SyntheticSpikes
2 | from .neuromorphic import NMNISTDataset, SHDDataset
3 | from .ephys import ValidNeuronQuery, Builder, NoiseBuilder, EphysDataset
4 |
--------------------------------------------------------------------------------
/src/datasets/ephys.py:
--------------------------------------------------------------------------------
1 | import os
2 | import glob
3 | import pickle
4 | from collections import defaultdict
5 |
6 | import numpy as np
7 | import pandas as pd
8 | import torch
9 | from allensdk.core.cell_types_cache import CellTypesCache
10 | from allensdk.ephys.ephys_extractor import EphysSweepFeatureExtractor
11 |
12 |
13 | class ValidNeuronQuery:
14 |
15 | """
16 | This class builds an idx set of neurons which meet the requirement of having 4 repeats.
17 | """
18 |
19 | def __init__(self, root):
20 | self.root = root
21 | self.query_df = self.build_query_df()
22 |
23 | @property
24 | def idx(self):
25 | query = self.query_df["n_trials"] == 4 # Want 4 repeats
26 | return self.query_df[query]["idx"].values
27 |
28 | def build_query_df(self):
29 | train_idxs = [v.split("/")[-1] for v in glob.glob(f"{self.root}/train/*")]
30 | test_idxs = [v.split("/")[-1] for v in glob.glob(f"{self.root}/test/*")]
31 | joint_idxs = list(set(train_idxs) & set(test_idxs))
32 |
33 | data_list = []
34 |
35 | for idx in joint_idxs:
36 | try:
37 | v = torch.load(f"{self.root}/test/{idx}/v.pt")
38 | n_trials = v.shape[0]
39 | data_list.append({"idx": idx, "n_trials": n_trials})
40 | except:
41 | pass
42 |
43 | return pd.DataFrame(data_list)
44 |
45 |
46 | class EphysDataset:
47 |
48 | """
49 | This is the PyTorch friendly e-phys dataset, with all the current and spike tensors.
50 | """
51 |
52 | LENGTH = 10000 # = 1 second (with DT=0.1)
53 |
54 | def __init__(self, root, dataset, neuron_idx, target_sampling_rate=None):
55 | # dataset: str which is train or test
56 | info_df = pd.read_csv(f"{root}/info_df.csv").set_index("idx")
57 |
58 | if target_sampling_rate is None:
59 | self.i = torch.load(f"{root}/{dataset}/{neuron_idx}/i.pt")[:, :, :1*EphysDataset.LENGTH]
60 | elif target_sampling_rate == 20000:
61 | self.i = torch.load(f"{root}/{dataset}/{neuron_idx}/i_20k.pt")[:, :, :2*EphysDataset.LENGTH]
62 | self.v = torch.load(f"{root}/{dataset}/{neuron_idx}/v.pt")[:, :, :EphysDataset.LENGTH]
63 | self.s = torch.load(f"{root}/{dataset}/{neuron_idx}/s.pt")[:, :, :EphysDataset.LENGTH]
64 |
65 | vrest = info_df.loc[neuron_idx]["vrest"]
66 | vthresh = info_df.loc[neuron_idx]["vthresh"]
67 | self.i = self.i / (100 * self.i.max())
68 | self.v = (self.v - vrest) / (vthresh - vrest)
69 |
70 | @property
71 | def hyperparams(self):
72 | return {}
73 |
74 | def __getitem__(self, item):
75 | x = self.i[0, item].unsqueeze(0) # Input current is the same across all trials
76 | trace = self.v[0, item].unsqueeze(0)
77 | trace = torch.clamp(trace, -1, 1)
78 | spikes = self.s[0, item].unsqueeze(0) # Take first spike trial (all are the same)
79 |
80 | return x, (trace, spikes)
81 |
82 | def __len__(self):
83 | return 3
84 |
85 |
86 | class Builder:
87 |
88 | def __init__(self, manifest_file="data/plot_data/allen-brain-observatory/cell_types/manifest.json"):
89 | self.ctc = CellTypesCache(manifest_file=manifest_file)
90 | ephys_df = self.generate_ephys_df()
91 | self.info_df = self.generate_info_df(ephys_df).set_index("idx")
92 |
93 | def save_info_df(self, path):
94 | self.info_df.to_csv(f"{path}/info_df.csv")
95 |
96 | def build(self, path, **kwargs):
97 | for neuron_idx in self.info_df.index:
98 | print(f"Building {neuron_idx}...")
99 | try:
100 | meta, i, v = self.generate_all_sweep_tensor(neuron_idx, **kwargs)
101 | os.mkdir(f"{path}/{neuron_idx}")
102 | torch.save(i, f"{path}/{neuron_idx}/i.pt")
103 | torch.save(v, f"{path}/{neuron_idx}/v.pt")
104 | with open(f"{path}/{neuron_idx}/meta.pkl", "wb") as f:
105 | pickle.dump(meta, f)
106 | except Exception as e:
107 | print(f"Failed {neuron_idx}: {e}")
108 |
109 | def generate_all_sweep_tensor(self, neuron_idx, target_sampling_rate=10000, start_s=1.02, end_s=1.3):
110 | sweep_idxs = self.info_df.loc[neuron_idx]["long_square"]
111 | sweep_dict = {}
112 |
113 | for sweep_idx in sweep_idxs:
114 | i, v, spike_times = self.generate_sweep_tensor(neuron_idx, sweep_idx, target_sampling_rate, start_s, end_s)
115 | sweep_dict[i] = (i, v, spike_times)
116 |
117 | # Sort from lowest to highest current
118 | sweep_dict = {k: v for k, v in sorted(sweep_dict.items(), key=lambda item: item[0])}
119 |
120 | meta = pd.DataFrame([{"i": v[0], "spikes": v[2]} for k, v in sweep_dict.items()])
121 | v = torch.stack([sweep_dict[key][1] for key in sweep_dict.keys()])
122 |
123 | return meta, v
124 |
125 | def generate_sweep_tensor(self, neuron_idx, sweep_number, target_sampling_rate=10000, start_s=1.02, end_s=1.3):
126 | data_set = self.ctc.get_ephys_data(neuron_idx)
127 | sweep_data = data_set.get_sweep(sweep_number)
128 |
129 | index_range = sweep_data["index_range"]
130 | i = sweep_data["stimulus"][0:index_range[1]+1] # in A
131 | v = sweep_data["response"][0:index_range[1]+1] # in V
132 | i *= 1e12 # to pA
133 | v *= 1e3 # to mV
134 |
135 | sampling_rate = int(sweep_data["sampling_rate"]) # in Hz
136 | t = np.arange(0, len(v)) * (1.0 / sampling_rate)
137 | downsample_factor = sampling_rate / target_sampling_rate
138 |
139 | sweep_ext = EphysSweepFeatureExtractor(t=t, v=v, i=i, start=start_s, end=end_s)
140 | sweep_ext.process_spikes()
141 | spike_times = sweep_ext.spike_feature("threshold_t")
142 |
143 | start_idx = int(start_s*sampling_rate)
144 | end_idx = int(end_s*sampling_rate)
145 |
146 | downsampled_v, downsampled_i = [], []
147 | assert v.shape == i.shape
148 |
149 | idx = start_idx
150 | while idx < end_idx:
151 | downsampled_v.append(v[int(idx)])
152 | downsampled_i.append(i[int(idx)])
153 | idx += downsample_factor
154 |
155 | return torch.Tensor(downsampled_i), torch.Tensor(downsampled_v), spike_times
156 |
157 | def generate_ephys_df(self):
158 | cells = {cell["id"]: cell for cell in self.ctc.get_cells()}
159 | ephys_features = self.ctc.get_ephys_features()
160 | ephys_df = pd.DataFrame(ephys_features)
161 | ephys_df['id'] = pd.Series([idx for idx in ephys_df['specimen_id']], index=ephys_df.index)
162 | ephys_df['species'] = pd.Series([cells[idx]['species'] for idx in ephys_df['specimen_id']], index=ephys_df.index)
163 | ephys_df['dendrite_type'] = pd.Series([cells[idx]['dendrite_type'] for idx in ephys_df['specimen_id']], index=ephys_df.index)
164 | ephys_df['structure_layer_name'] = pd.Series([cells[idx]['structure_layer_name'] for idx in ephys_df['specimen_id']], index=ephys_df.index)
165 | ephys_df['disease_state'] = pd.Series([cells[idx]['disease_state'] for idx in ephys_df['specimen_id']], index=ephys_df.index)
166 | query = ephys_df["structure_layer_name"] == "4"
167 | query &= ephys_df["species"] == "Mus musculus"
168 | query &= ephys_df["disease_state"] == ""
169 |
170 | return ephys_df[query]
171 |
172 | def generate_info_df(self, ephys_df):
173 | info_list = []
174 |
175 | neuron_idxs = ephys_df["id"].values
176 |
177 | for neuron_idx in neuron_idxs:
178 | # Sweep info
179 | sweeps = self.ctc.get_ephys_sweeps(neuron_idx)
180 | sweep_numbers = defaultdict(list)
181 | for sweep in sweeps:
182 | sweep_numbers[sweep['stimulus_name']].append(sweep['sweep_number'])
183 |
184 | neuron_type = ephys_df[ephys_df["id"] == neuron_idx]["dendrite_type"].values[0]
185 | vrest = ephys_df[ephys_df["id"] == neuron_idx]["vrest"].values[0]
186 | vthresh = ephys_df[ephys_df["id"] == neuron_idx]["threshold_v_long_square"].values[0]
187 |
188 | info_list.append({"idx": neuron_idx, "type": neuron_type, "long_square": sweep_numbers.get("Long Square"), "noise1": sweep_numbers.get("Noise 1"), "noise2": sweep_numbers.get("Noise 2"), "test": sweep_numbers.get("Test"), "vrest": vrest, "vthresh": vthresh})
189 |
190 | return pd.DataFrame(info_list)
191 |
192 |
193 | class NoiseBuilder(Builder):
194 |
195 | def build(self, path, **kwargs):
196 | for neuron_idx in self.info_df.index:
197 | print(f"Building {neuron_idx}...")
198 | try:
199 | if not os.path.exists(f"{path}/{neuron_idx}"):
200 | os.makedirs(f"{path}/{neuron_idx}")
201 |
202 | i, v, s = self.generate_all_sweep_tensor(neuron_idx, **kwargs)
203 |
204 | # Default used for all experiments
205 | if kwargs.get("target_sampling_rate") is None:
206 | torch.save(i, f"{path}/{neuron_idx}/i.pt")
207 | torch.save(v, f"{path}/{neuron_idx}/v.pt")
208 | torch.save(s, f"{path}/{neuron_idx}/s.pt")
209 | # Re-ran some experiments with DT=0.05ms as requested by one reviewer
210 | elif kwargs.get("target_sampling_rate") == 20000:
211 | torch.save(i, f"{path}/{neuron_idx}/i_20k.pt")
212 | torch.save(v, f"{path}/{neuron_idx}/v_20k.pt")
213 | torch.save(s, f"{path}/{neuron_idx}/s_20k.pt")
214 |
215 | except Exception as e:
216 | print(f"Failed {neuron_idx}: {e}")
217 |
218 | def generate_all_sweep_tensor(self, neuron_idx, target_sampling_rate=10000, noise_type="noise1"):
219 | sweep_idxs = self.info_df.loc[neuron_idx][noise_type]
220 | assert len(sweep_idxs) is not None
221 |
222 | i_list = []
223 | v_list = []
224 | s_list = []
225 |
226 | for sweep_idx in sweep_idxs:
227 | i, v, s = self.generate_noise_sweep_tensor(neuron_idx, sweep_idx, target_sampling_rate)
228 | i_list.append(i)
229 | v_list.append(v)
230 | s_list.append(s)
231 |
232 | return torch.stack(i_list), torch.stack(v_list), torch.stack(s_list)
233 |
234 | def generate_noise_sweep_tensor(self, neuron_idx, sweep_number, target_sampling_rate=10000):
235 | i1, v1, t1 = self.generate_sweep_tensor(neuron_idx, sweep_number, target_sampling_rate, start_s=2, end_s=5)
236 | i2, v2, t2 = self.generate_sweep_tensor(neuron_idx, sweep_number, target_sampling_rate, start_s=10, end_s=13)
237 | i3, v3, t3 = self.generate_sweep_tensor(neuron_idx, sweep_number, target_sampling_rate, start_s=18, end_s=21)
238 | t1 -= 2
239 | t2 -= 10
240 | t3 -= 18
241 | s1 = NoiseBuilder.to_spike_target(t1, target_sampling_rate)
242 | s2 = NoiseBuilder.to_spike_target(t2, target_sampling_rate)
243 | s3 = NoiseBuilder.to_spike_target(t3, target_sampling_rate)
244 |
245 | return torch.stack([i1, i2, i3]), torch.stack([v1, v2, v3]), torch.stack([s1, s2, s3])
246 |
247 | @staticmethod
248 | def to_spike_target(spike_times, target_sampling_rate):
249 | dt = 0.0001
250 | spike_target = torch.zeros(target_sampling_rate)
251 | spike_idx = [int(spike_time // dt) for spike_time in spike_times if int(spike_time // dt) < target_sampling_rate]
252 | spike_target[spike_idx] = 1
253 |
254 | return spike_target
255 |
--------------------------------------------------------------------------------
/src/datasets/neuromorphic.py:
--------------------------------------------------------------------------------
1 | import os
2 | import tables
3 |
4 | import torch
5 | import numpy as np
6 | from brainbox.datasets import BBDataset
7 |
8 | from src.datasets.transforms import SpikeTensorBuilder
9 |
10 |
11 | class H5Dataset(BBDataset):
12 |
13 | def __init__(self, root, train, n_in, n_out, t_len, train_name, test_name, dt):
14 | self._file = None
15 | self._train_name = train_name
16 | self._test_name = test_name
17 | super().__init__(root, train, lambda dataset: H5Dataset.preprocess(dataset), SpikeTensorBuilder(n_in, t_len, dt))
18 | self._file.close()
19 |
20 | self._n_in = n_in
21 | self._n_out = n_out
22 | self._t_len = t_len
23 | self._dt = dt
24 |
25 | @property
26 | def hyperparams(self):
27 | return {**super().hyperparams, "t_len": self._t_len, "dt": self._dt}
28 |
29 | @property
30 | def n_in(self):
31 | return self._n_in
32 |
33 | @property
34 | def n_out(self):
35 | return self._n_out
36 |
37 | @property
38 | def t_len(self):
39 | return self._t_len
40 |
41 | @property
42 | def dt(self):
43 | return self._dt
44 |
45 | @staticmethod
46 | def preprocess(dataset):
47 | processed_dataset = []
48 | units, times = dataset
49 |
50 | for i in range(len(units)):
51 | item_units = torch.Tensor(np.array(units[i], dtype=np.int))
52 | item_times = torch.Tensor(np.array(times[i], dtype=np.float))
53 | processed_dataset.append((item_units, item_times))
54 |
55 | return processed_dataset
56 |
57 | @staticmethod
58 | def _open_file(hdf5_file_path):
59 | fileh = tables.open_file(hdf5_file_path, mode="r")
60 | units = fileh.root.spikes.units
61 | times = fileh.root.spikes.times
62 | labels = fileh.root.labels
63 |
64 | return fileh, units, times, labels
65 |
66 | def _load_dataset(self, train):
67 | name = self._train_name if train else self._test_name
68 | fileh, units, times, labels = H5Dataset._open_file(os.path.join(self._root, name))
69 | targets = torch.Tensor(labels)
70 | self._file = fileh
71 |
72 | return (units, times), targets
73 |
74 |
75 | class NMNISTDataset(H5Dataset):
76 |
77 | T_LEN = 400
78 |
79 | def __init__(self, root, train=True, dt=1):
80 | t_len = int(NMNISTDataset.T_LEN / dt)
81 | super().__init__(root, train, n_in=1156, n_out=10, t_len=t_len, train_name="train.h5", test_name="test.h5", dt=dt)
82 |
83 | @property
84 | def name(self):
85 | return "nmnist"
86 |
87 |
88 | class SHDDataset(H5Dataset):
89 |
90 | T_LEN = 1200
91 |
92 | def __init__(self, root, train=True, dt=2):
93 | t_len = int(SHDDataset.T_LEN / dt)
94 | super().__init__(root, train, n_in=700, n_out=20, t_len=t_len, train_name="shd_train.h5", test_name="shd_test.h5", dt=dt)
95 |
96 | @property
97 | def name(self):
98 | return "shd"
99 |
--------------------------------------------------------------------------------
/src/datasets/synthetic.py:
--------------------------------------------------------------------------------
1 | import torch
2 | from torch.distributions.poisson import Poisson
3 | from brainbox.datasets import BBDataset
4 |
5 |
6 | class SyntheticSpikes(BBDataset):
7 |
8 | """
9 | This is the synthetic spike dataset with which the model was benchmarked.
10 | """
11 |
12 | def __init__(self, t_len, n_units, min_r, max_r, n_samples):
13 | super().__init__(None)
14 | self.t_len = t_len
15 | self.n_units = n_units
16 | self.min_r = min_r
17 | self.max_r = max_r
18 | self.n_samples = n_samples
19 |
20 | def __getitem__(self, i):
21 | rate = torch.FloatTensor(1).uniform_(self.min_r, self.max_r).item()
22 | x = self._create_spikes(rate, self.n_units, self.t_len)
23 |
24 | return x
25 |
26 | def __len__(self):
27 | return self.n_samples
28 |
29 | def _load_dataset(self, train):
30 | return None, None
31 |
32 | def _create_spikes(self, rate, n_units, t_len):
33 | pois_dis = Poisson(rate/t_len)
34 | if type(n_units) == tuple:
35 | samples = pois_dis.sample(sample_shape=(*n_units, t_len))
36 | else:
37 | samples = pois_dis.sample(sample_shape=(n_units, t_len))
38 | samples[samples > 1] = 1
39 |
40 | return samples
--------------------------------------------------------------------------------
/src/datasets/transforms.py:
--------------------------------------------------------------------------------
1 | import torch
2 | import numpy as np
3 | import brainbox
4 | from brainbox.datasets.transforms import BBTransform
5 |
6 |
7 | class SpikeTensorBuilder(BBTransform):
8 |
9 | def __init__(self, n_units, t_len, dt):
10 | self._n_units = n_units
11 | self._t_len = t_len
12 | self._dt = dt
13 |
14 | def __call__(self, args):
15 | units, times = args[0], args[1]
16 | units = units % self._n_units
17 | times = torch.round(times * 1000. / self._dt).int()
18 |
19 | # Constrain spike length
20 | idxs = (times < self._t_len)
21 | units = units[idxs]
22 | times = times[idxs]
23 |
24 | # Build COO tensor
25 | indices = torch.stack([torch.Tensor(units.tolist()), torch.Tensor(times.tolist())], dim=0).long()
26 | shape = torch.Size([self._n_units, self._t_len, ])
27 | spikes = torch.FloatTensor(np.ones(len(indices[0])))
28 |
29 | return torch.sparse.FloatTensor(indices, spikes, shape).to_dense()
30 |
31 |
32 | class List:
33 |
34 | @staticmethod
35 | def get_nmnist_transform(t_len, use_augmentation=False):
36 | if use_augmentation:
37 | raise NotImplementedError
38 | else:
39 | transform_list = [SpikeTensorBuilder(n_units=1156, t_len=t_len, dt=1)]
40 |
41 | return brainbox.datasets.transforms.Compose(transform_list)
42 |
43 | @staticmethod
44 | def get_shd_transform(t_len):
45 | transform_list = [SpikeTensorBuilder(n_units=700, t_len=t_len, dt=2)]
46 |
47 | return brainbox.datasets.transforms.Compose(transform_list)
48 |
--------------------------------------------------------------------------------
/src/metric.py:
--------------------------------------------------------------------------------
1 | import torch
2 | import pandas as pd
3 | from brainbox.physiology.spiking import SpikeToPSTH
4 |
5 | from src import datasets, train
6 |
7 |
8 | class SpikeTrainEV:
9 |
10 | def __init__(self, st_len, sig):
11 | self._spike_smoother = SpikeToPSTH(st_len, sig)
12 |
13 | def __call__(self, input, target):
14 | # input: b x 3 x t
15 | smooth_input_trains = self.smooth_spike_trains(input)
16 | smooth_target_trains = self.smooth_spike_trains(target)
17 | flatten_input_trains = self.flatten_spike_trains(smooth_input_trains)
18 | flatten_target_trains = self.flatten_spike_trains(smooth_target_trains)
19 |
20 | return SpikeTrainEV.ev(flatten_input_trains, flatten_target_trains.mean(0).unsqueeze(0)).mean()
21 |
22 | def smooth_spike_trains(self, spike_trains):
23 | # spike_trains: b x 3 x t
24 | b = spike_trains.shape[0]
25 |
26 | return self._spike_smoother(spike_trains.view(b*3, -1).unsqueeze(1))[:, 0].view(b, 3, -1)
27 |
28 | def flatten_spike_trains(self, spike_trains):
29 | # spike_trains: b x 3 x t
30 | return spike_trains.flatten(1, 2)
31 |
32 | @staticmethod
33 | def ev(input, target):
34 | # input: b x t
35 | return (input.var(1) + target.var(1) - (input - target).var(1)) / (input.var(1) + target.var(1))
36 |
37 |
38 | class EphysAnalysis:
39 |
40 | def __init__(self, root, method, abs_refac_ms, downsample, taus=[100]):
41 | self.root = root
42 | self.method = method
43 | self.abs_refac_ms = abs_refac_ms
44 | self.downsample = downsample
45 | self.taus = taus
46 |
47 | self.metrics = {tau: SpikeTrainEV(10000, tau) for tau in taus}
48 | self.neuron_idxs = datasets.ValidNeuronQuery(f"{root}/data/ephys").idx
49 |
50 | # Init test dataset
51 | self.test_dataset = {}
52 | self.norm_factors = {tau: {} for tau in taus}
53 |
54 | for neuron_idx in self.neuron_idxs:
55 | if downsample == 0.5:
56 | self.test_dataset[neuron_idx] = datasets.EphysDataset(f"{root}/data/ephys", "test", int(neuron_idx), target_sampling_rate=20000)
57 | else:
58 | self.test_dataset[neuron_idx] = datasets.EphysDataset(f"{root}/data/ephys", "test", int(neuron_idx))
59 | target_spikes = self.test_dataset[neuron_idx].s.cpu()
60 | for tau in taus:
61 | self.norm_factors[tau][neuron_idx] = self.metrics[tau](target_spikes, target_spikes).item()
62 |
63 | self._norm_df = {tau: pd.Series(self.norm_factors[tau]).to_frame().rename(columns={0: "score"}) for tau in taus}
64 | self._ev_df = None
65 |
66 | def ev_df(self, tau, normalise):
67 | if self._ev_df is None:
68 | self._ev_df = self._build_ev_df().set_index("neuron_idx")
69 |
70 | query = self._ev_df["tau"] == tau
71 | ev_df = self._ev_df[query]["score"].to_frame()
72 |
73 | if normalise:
74 | df = ev_df / self._norm_df[tau]
75 | else:
76 | df = ev_df
77 |
78 | df.index = df.index.map(int)
79 |
80 | return df
81 |
82 | def get_times_df(self):
83 | dir_name = f"{self.method}_{self.abs_refac_ms}_{self.downsample}"
84 |
85 | times_list = []
86 |
87 | for neuron_idx in self.neuron_idxs:
88 | times_csv = pd.read_csv(f"{self.root}/results/ephys/{dir_name}/{neuron_idx}/times.csv")
89 | forward_pass, backward_pass = times_csv.sum()
90 | times_list.append({"neuron_idx": neuron_idx, "forward_pass": forward_pass, "backward_pass": backward_pass})
91 |
92 | return pd.DataFrame(times_list)
93 |
94 | def _build_ev_df(self):
95 | dir_name = f"{self.method}_{self.abs_refac_ms}_{self.downsample}"
96 |
97 | metric_list = []
98 |
99 | for i, neuron_idx in enumerate(self.neuron_idxs):
100 | print(f"Building {i}/{len(self.neuron_idxs)} {neuron_idx}...")
101 | dt01ref = self.downsample == 0.5
102 | neuron = train.EphysTrainer.load_model(f"{self.root}/results/ephys/{dir_name}", neuron_idx, dt01ref=dt01ref)
103 |
104 | with torch.no_grad():
105 | test_dataset = self.test_dataset[neuron_idx]
106 | pred_spikes = neuron(test_dataset.i[0].unsqueeze(1).cuda()).permute(1, 0, 2).cpu()
107 | target_spikes = test_dataset.s.cpu()
108 |
109 | for tau in self.taus:
110 | score = self.metrics[tau](target_spikes, pred_spikes).item() # note: model spikes are reference
111 | metric_list.append({"neuron_idx": neuron_idx, "score": score, "tau": tau})
112 |
113 | return pd.DataFrame(metric_list)
114 |
115 | def load_prediction(self, neuron_idx): # Load prediction for a certain fit and neuron
116 | test_dataset = self.test_dataset[str(neuron_idx)]
117 |
118 | with torch.no_grad():
119 | dir_name = f"{self.method}_{self.abs_refac_ms}_{self.downsample}"
120 | dt01ref = self.downsample == 0.5
121 | neuron = train.EphysTrainer.load_model(f"{self.root}/results/ephys/{dir_name}", neuron_idx, dt01ref=dt01ref)
122 |
123 | output = neuron(test_dataset.i[0].unsqueeze(1).cuda(), mode="val")
124 | spikes = output[0].permute(1, 0, 2).cpu()
125 | mem = output[1].permute(1, 0, 2).cpu()
126 |
127 | return spikes, mem, test_dataset.s, test_dataset.v, test_dataset.i
128 |
129 | def load_params_df(self):
130 | dir_name = f"{self.method}_{self.abs_refac_ms}_{self.downsample}"
131 |
132 | params_list = []
133 |
134 | for i, neuron_idx in enumerate(self.neuron_idxs):
135 | print(f"Building {i}/{len(self.neuron_idxs)} {neuron_idx}...")
136 | neuron = train.EphysTrainer.load_model(f"{self.root}/results/ephys/{dir_name}", neuron_idx).neuron
137 |
138 | params_list.append({"neuron_idx": neuron_idx, "beta": neuron.beta.item(), "p": neuron.p.item(), "b": neuron.b.item()})
139 |
140 | return pd.DataFrame(params_list)
141 |
--------------------------------------------------------------------------------
/src/models.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import torch
3 | import torch.nn as nn
4 | import torch.nn.functional as F
5 | from brainbox import models
6 |
7 | import src.snn.block.blocks as blocks
8 | import src.snn.snn as snn
9 |
10 |
11 | class AuditoryModel(models.BBModel):
12 |
13 | # AuditoryModel name comes from the SHD dataset being auditory
14 |
15 | HIDDEN_MEM_TIME = 20
16 | HIDDEN_ADAPT_TIME = 150
17 | READOUT_MEM_TIME = 20
18 |
19 | def __init__(self, method, n_in, n_hidden, n_out, t_len, abs_refac, recurrent=True, dt=1, surr_grad="fast_sigmoid", detach_spike_grad=True):
20 | super().__init__()
21 | self._method = method
22 | self._n_in = n_in
23 | self._n_hidden = n_hidden
24 | self._n_out = n_out
25 | self._t_len = t_len
26 | self._abs_refac = abs_refac
27 | self._recurrent = recurrent
28 | self._dt = dt
29 | self._surr_grad = surr_grad
30 |
31 | init_hidden_beta = np.exp(-dt / AuditoryModel.HIDDEN_MEM_TIME)
32 | init_hidden_p = np.exp(-dt / AuditoryModel.HIDDEN_ADAPT_TIME)
33 | init_readout_beta = np.exp(-dt / AuditoryModel.READOUT_MEM_TIME)
34 |
35 | if method == "standard":
36 | self._thalamic_layer = snn.SNN(n_in, n_hidden, 1, t_len, abs_refac, recurrent, beta_grad=True, adapt=True, init_beta=init_hidden_beta, init_p=init_hidden_p, surr_grad=self._surr_grad)
37 | self._cortical_layer = snn.SNN(n_hidden, n_hidden, 1, t_len, abs_refac, recurrent, beta_grad=True, adapt=True, init_beta=init_hidden_beta, init_p=init_hidden_p, surr_grad=self._surr_grad)
38 | self._output = snn.SNNIntegrator(n_hidden, n_out, t_len, init_beta=init_readout_beta)
39 | else:
40 | self._thalamic_layer = blocks.Blocks(n_in, n_hidden, 1, t_len, abs_refac, recurrent, beta_grad=True, adapt=True, init_beta=init_hidden_beta, init_p=init_hidden_p, surr_grad=self._surr_grad, detach_spike_grad=detach_spike_grad)
41 | self._cortical_layer = blocks.Blocks(n_hidden, n_hidden, 1, t_len, abs_refac, recurrent, beta_grad=True, adapt=True, init_beta=init_hidden_beta, init_p=init_hidden_p, surr_grad=self._surr_grad, detach_spike_grad=detach_spike_grad)
42 | self._output = blocks.BlocksIntegrator(n_hidden, n_out, t_len, init_beta=init_readout_beta)
43 |
44 | @property
45 | def hyperparams(self):
46 | return {**super().hyperparams, "method": self._method, "n_in": self._n_in, "n_hidden": self._n_hidden, "n_out": self._n_out, "t_len": self._t_len, "abs_refac": self._abs_refac, "recurrent": self._recurrent, "dt": self._dt, "surr_grad": self._surr_grad}
47 |
48 | def forward(self, x, mode="train"):
49 | # x: b x n x t
50 | thalamic_output = self._thalamic_layer(x, mode)
51 | cortical_output = self._cortical_layer(thalamic_output if mode == "train" else thalamic_output[0], mode)
52 |
53 | if mode == "train":
54 | return self._output(cortical_output, mode).sum(2)
55 | else:
56 | return self._output(cortical_output[0], mode).sum(2), cortical_output, thalamic_output
57 |
58 |
59 | class ModelBuilder(models.BBModel):
60 |
61 | def __init__(self, method, t_len, abs_refac, n_in, n_hidden, n_layers):
62 | super().__init__()
63 | self._layers = nn.ModuleList()
64 |
65 | for i in range(n_layers):
66 | n_in = n_in if i == 0 else n_hidden
67 | if method == "standard":
68 | self._layers.append(snn.SNN(n_in, n_hidden, 1, t_len, abs_refac, recurrent=True, beta_grad=True, adapt=True, init_beta=0.9, init_p=0.9, surr_grad="mg"))
69 | else:
70 | self._layers.append(blocks.Blocks(n_in, n_hidden, 1, t_len, abs_refac, recurrent=True, beta_grad=True, adapt=True, init_beta=0.9, init_p=0.9, surr_grad="mg"))
71 |
72 | def forward(self, x):
73 | for layer in self._layers:
74 | x = layer(x)
75 |
76 | return x
77 |
78 |
79 | class Neuron(models.BBModel):
80 |
81 | def __init__(self, method, abs_refac_ms, downsample=1, dt01ref=False):
82 | super().__init__()
83 | self.method = method
84 | self.abs_refac_ms = abs_refac_ms
85 | self.downsample = downsample
86 | self.dt01ref = dt01ref
87 | self.dt_ms = downsample * 0.1 # Larger dt_ms == downsample temporal resolution
88 |
89 | if not dt01ref:
90 | self.neuron = Neuron.get_neuron(method, abs_refac_ms, self.dt_ms, downsample)
91 | else:
92 | self.neuron = Neuron.get_neuron(method, abs_refac_ms, 0.5*self.dt_ms, 0.5*downsample)
93 | upsample_kernel = torch.zeros(downsample)
94 | upsample_kernel[0] = 1
95 | self.upsample_kernel = nn.Parameter(upsample_kernel.view(1, 1, -1), requires_grad=False)
96 |
97 | @property
98 | def hyperparams(self):
99 | return {**super().hyperparams, "method": self.method, "abs_refac_ms": self.abs_refac_ms, "downsample": self.downsample, "dt01ref": self.dt01ref}
100 |
101 | def forward(self, x, mode="train"):
102 | x = F.avg_pool1d(x, self.downsample, self.downsample) # Down sample the signal (if need be)
103 | spikes = self.neuron(x, mode)
104 |
105 | # Return data for plotting
106 | if mode == "val":
107 | spikes, mem = spikes[0], spikes[1]
108 |
109 | return spikes, mem
110 |
111 | # Return predicted spike train for training
112 | if self.dt01ref: # When running in DT=0.05ms (was added on to run additional experiments for a reviewer)
113 | spikes = F.max_pool1d(spikes, 2, 2)
114 | return spikes
115 |
116 | if self.downsample == 1:
117 | return spikes
118 | else:
119 | return F.conv_transpose1d(spikes, self.upsample_kernel, stride=self.downsample)
120 |
121 | @staticmethod
122 | def get_neuron(method, abs_refac_ms, dt_ms, downsample):
123 | init_beta = np.exp(-dt_ms / 20)
124 | init_p = np.exp(-dt_ms / 100)
125 | t_len = int(1000 / dt_ms)
126 | abs_refac_ms = int(abs_refac_ms / dt_ms)
127 |
128 | if method == "blocks":
129 | neuron = blocks.Blocks(1, 1, rf_len=1, t_len=t_len, t_latency=abs_refac_ms, recurrent=False, beta_grad=True, adapt=True, init_beta=init_beta, init_p=init_p, detach_spike_grad=True, surr_grad="mg")
130 | else:
131 | neuron = snn.SNN(1, 1, rf_len=1, t_len=t_len, t_latency=abs_refac_ms, recurrent=False, beta_grad=True, adapt=True, init_beta=init_beta, init_p=init_p, detach_spike_grad=True, surr_grad="mg")
132 |
133 | neuron.init_weight(neuron._rf_weight, "constant", c=downsample)
134 | neuron._b = nn.Parameter(data=torch.Tensor([0.1 / downsample]), requires_grad=True)
135 |
136 | return neuron
137 |
--------------------------------------------------------------------------------
/src/query.py:
--------------------------------------------------------------------------------
1 | import glob
2 |
3 | import pandas as pd
4 | from brainbox import trainer
5 |
6 | from src import models, train
7 |
8 |
9 | models.snn.BaseSNN.MIN_BETA = 0.01
10 | models.snn.BaseSNN.MAX_BETA = 0.99
11 |
12 |
13 | class BenchmarkQuery:
14 |
15 | def __init__(self, root, batches=[32, 64, 128]):
16 | self._root = root
17 |
18 | self._results_df = self._build_df()
19 | self._results_df = pd.concat([self._query_results(batch=b) for b in batches])
20 |
21 | def _build_df(self):
22 | results_df_list = []
23 |
24 | for path in self._get_paths(self._root):
25 | results_df_list.append(pd.read_csv(path))
26 |
27 | results_df = pd.concat(results_df_list)
28 | results_df["total_time"] = results_df["forward_time"] + results_df["backward_time"]
29 |
30 | return results_df
31 |
32 | def _get_paths(self, root):
33 | return [path for path in glob.glob(f"{root}/*")]
34 |
35 | def _query_results(self, **kwargs):
36 | query = True
37 | for key, value in kwargs.items():
38 | query &= self._results_df[key] == value
39 |
40 | if len(kwargs) > 0:
41 | return self._results_df[query]
42 |
43 | return self._results_df
44 |
45 | def get_speedup(self):
46 | results_df = self._build_df()
47 | standard_times = results_df[results_df["method"] == "standard"].set_index(["t_len", "units", "batch", "abs_refac", "layers"])[["forward_time", "backward_time", "total_time"]]
48 | blocks_times = results_df[results_df["method"] == "blocks"].set_index(["t_len", "units", "batch", "abs_refac", "layers"])[["forward_time", "backward_time", "total_time"]]
49 |
50 | speedup_df = standard_times / blocks_times
51 | speedup_df.rename(columns={"forward_time": "forward_speedup", "backward_time": "backward_speedup", "total_time": "total_speedup"}, inplace=True)
52 |
53 | return speedup_df
54 |
55 |
56 | class SupervisedQuery:
57 |
58 | def __init__(self, root):
59 | self.root = root
60 |
61 | def get_average_duration_per_batch(self, models_root, model_id):
62 | durations_list = []
63 |
64 | duration = trainer.load_log(models_root, model_id)["duration"][1:].mean()
65 | durations_list.append({"model_id": model_id, "duration": duration})
66 |
67 | return pd.DataFrame(durations_list).set_index("model_id").values[0][0]
68 |
69 | def build_results(self, dataset, methods, sgs, abs_refacs, repeats, detach=True, batch_size=500):
70 | results_list = []
71 |
72 | for method in methods:
73 | for sg in sgs:
74 | for abs_refac in abs_refacs:
75 | for i in range(repeats):
76 | if detach:
77 | name = f"{dataset.name}_{method}_{sg}_{abs_refac}_{dataset.dt}_{i}"
78 | else:
79 | name = f"{dataset.name}_{method}_{sg}_{abs_refac}_{dataset.dt}_{detach}_{i}"
80 | print(f"Loading {name}...")
81 | model = train.Trainer.load_model(f"{self.root}/results/supervised", name)
82 | val_acc = train.Trainer.get_acc(model, dataset, batch_size)
83 | avg_time = self.get_average_duration_per_batch(f"{self.root}/results/supervised", name)
84 | results_list.append({"dataset": dataset.name, "method": method, "sg": sg, "abs_refac": abs_refac, "dt": dataset.dt, "i": i, "val_acc": val_acc, "avg_time": avg_time})
85 |
86 | return pd.DataFrame(results_list)
--------------------------------------------------------------------------------
/src/snn/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webstorms/Blocks/540c9f28eedd58ef638dcacf75b4c27cdf52baa0/src/snn/__init__.py
--------------------------------------------------------------------------------
/src/snn/block/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webstorms/Blocks/540c9f28eedd58ef638dcacf75b4c27cdf52baa0/src/snn/block/__init__.py
--------------------------------------------------------------------------------
/src/snn/block/block.py:
--------------------------------------------------------------------------------
1 | import torch
2 | import torch.nn as nn
3 | import torch.nn.functional as F
4 |
5 | from src.snn.block.util import bconv1d
6 | from src.snn import surrogate
7 |
8 |
9 | class Block(nn.Module):
10 |
11 | def __init__(self, n_in, t_len, surr_grad):
12 | super().__init__()
13 | self._n_in = n_in
14 | self._t_len = t_len
15 | self._surr_grad = surr_grad
16 |
17 | self._beta_ident_base = nn.Parameter(torch.ones(n_in, t_len), requires_grad=False)
18 | self._beta_exp = nn.Parameter(torch.arange(t_len).flip(0).unsqueeze(0).expand(n_in, t_len).float(), requires_grad=False)
19 | self._phi_kernel = nn.Parameter((torch.arange(t_len) + 1).flip(0).float().view(1, 1, 1, t_len), requires_grad=False)
20 |
21 | @staticmethod
22 | def g(faulty_spikes):
23 | negate_faulty_spikes = faulty_spikes.clone().detach()
24 | negate_faulty_spikes[faulty_spikes == 1.0] = 0
25 | faulty_spikes -= negate_faulty_spikes
26 |
27 | return faulty_spikes
28 |
29 | def forward(self, current, beta, v_init=None, v_th=1, mode="train"):
30 |
31 | if v_init is not None:
32 | current[:, :, 0] += beta * v_init
33 |
34 | pad_current = F.pad(current, pad=(self._t_len - 1, 0)).unsqueeze(1)
35 |
36 | # compute membrane potential without reset
37 | beta_kernel = self.build_beta_kernel(beta)
38 | membrane = bconv1d(pad_current, beta_kernel)
39 |
40 | # map no-reset membrane potentials to output spikes
41 | v_th = v_th.unsqueeze(1)
42 | faulty_spikes = surrogate.spike(membrane - v_th, self._surr_grad)
43 |
44 | pad_spikes = F.pad(faulty_spikes, pad=(self._t_len - 1, 0))
45 | z = F.conv2d(pad_spikes, self._phi_kernel)
46 | z_copy = z.clone().squeeze(1)
47 |
48 | if mode == "train":
49 | return Block.g(z).squeeze(1), z_copy, membrane.squeeze(1)
50 | elif mode == "val":
51 | return Block.g(z).squeeze(1), z_copy, faulty_spikes, membrane.squeeze(1)
52 |
53 | def build_beta_kernel(self, beta):
54 | beta_base = beta.unsqueeze(1).multiply(self._beta_ident_base)
55 | return torch.pow(beta_base, self._beta_exp).unsqueeze(1).unsqueeze(1)
56 |
--------------------------------------------------------------------------------
/src/snn/block/blocks.py:
--------------------------------------------------------------------------------
1 | import math
2 | import torch
3 | import torch.nn as nn
4 | import torch.nn.functional as F
5 |
6 | from src.snn.snn import BaseSNN
7 | from src.snn.block.block import Block
8 | from src.snn.block.util import time_cat, bconv1d
9 |
10 |
11 | class Blocks(BaseSNN):
12 |
13 | def __init__(self, n_in, n_out, rf_len, t_len, t_latency, recurrent=True, beta_grad=True, adapt=True, init_beta=1, init_p=1, detach_spike_grad=True, surr_grad="fast_sigmoid"):
14 | super().__init__(n_in, n_out, rf_len, t_len, t_latency, recurrent, beta_grad, adapt, init_beta, init_p, detach_spike_grad, surr_grad)
15 |
16 | self._t_len_block = t_latency + 1
17 | self._block = Block(n_out, self._t_len_block, surr_grad)
18 | self._n_blocks = math.ceil(t_len / self._t_len_block)
19 | self._t_pad = self._n_blocks * self._t_len_block - self._t_len
20 |
21 | self._p_ident_base = nn.Parameter(torch.ones(n_out, self._t_len_block), requires_grad=False)
22 | self._p_exp = nn.Parameter(torch.arange(1, self._t_len_block + 1).float(), requires_grad=False)
23 |
24 | def process(self, x, mode="train"):
25 | x_init = x
26 | if self._t_pad != 0:
27 | x = F.pad(x, pad=(0, self._t_pad))
28 |
29 | mem_list = []
30 | spikes_list = []
31 | z_list = []
32 |
33 | z = torch.zeros_like(x[:, :, self._t_len_block:])
34 | v_init = torch.zeros_like(x[:, :, 0]).to(x.device)
35 | int_mem = torch.zeros_like(x[:, :, 0]).to(x.device)
36 |
37 | a_kernel = torch.zeros_like(x).to(x.device)[:, :, :self._t_len_block]
38 | v_th = torch.ones_like(x).to(x.device)[:, :, :self._t_len_block]
39 | v_th_list = []
40 |
41 | for i in range(self._n_blocks):
42 | x_slice = x[:, :, i * self._t_len_block: (i+1) * self._t_len_block]
43 |
44 | # Recurrent current and refractory mask only included after first block
45 | if i > 0:
46 | # Add recurrent current to input
47 | if self._recurrent:
48 | rec_current = self.get_rec_input(spikes)
49 | x_slice = x_slice + rec_current
50 |
51 | # Apply refractory mask to input
52 | if self._detach_spike_grad:
53 | spike_mask = spikes.detach().amax(dim=2).bool()
54 | else:
55 | spike_mask = spikes.amax(dim=2).bool()
56 | refac_mask = (z < spike_mask.unsqueeze(2)) * x_slice
57 | x_slice -= refac_mask
58 |
59 | # Set initial membrane potentials
60 | v_init = int_mem[:, :, -1] * ~spike_mask # if spiked -> zero initial membrane potential
61 |
62 | # Set initial adaptive params
63 | if self._adapt:
64 | # Get a at time of spike + spike (which is equal to 1/p to account for raising v_th by 1 next step
65 | # do the math or see paper if this is not clear)
66 | if self._detach_spike_grad:
67 | a_at_spike = (a_kernel * spikes.detach()).sum(dim=2) + (1 / self.p)
68 | else:
69 | a_at_spike = (a_kernel * spikes).sum(dim=2) + (1 / self.p)
70 | decay_steps = (z > 1).sum(dim=2) # Compute number of decay steps
71 | new_a = a_at_spike * torch.pow(self.p.unsqueeze(0), decay_steps)
72 | a = (a_kernel[:, :, -1] * ~spike_mask) + (new_a * spike_mask)
73 |
74 | # Update a for neurons that spiked
75 | a_kernel = self.compute_a_kernel(a, self.p)
76 | v_th = 1 + self.b.view(1, -1, 1) * a_kernel
77 |
78 | if mode == "train":
79 | spikes, z, int_mem = self._block(x_slice, self.beta, v_init=v_init, v_th=v_th, mode="train")
80 | spikes_list.append(spikes)
81 | elif mode == "val":
82 | spikes, z, _, int_mem = self._block(x_slice, self.beta, v_init=v_init, v_th=v_th, mode="val")
83 | spikes_list.append(spikes)
84 | mem_list.append(int_mem)
85 | z_list.append(z)
86 | v_th_list.append(v_th)
87 |
88 | if mode == "train":
89 | return time_cat(spikes_list, self._t_pad)
90 | elif mode == "val":
91 | return time_cat(spikes_list, self._t_pad), time_cat(mem_list, self._t_pad), x_init, time_cat(z_list, self._t_pad), time_cat(v_th_list, self._t_pad)
92 |
93 | def compute_a_kernel(self, a, p):
94 | # a: b x n
95 | # p: n
96 | # output: b x n x t
97 |
98 | return torch.pow(p.unsqueeze(-1) * self._p_ident_base, self._p_exp).unsqueeze(0) * a.unsqueeze(-1)
99 |
100 |
101 | class BlocksIntegrator(BaseSNN):
102 |
103 | def __init__(self, n_in, n_out, t_len, init_beta=1):
104 | super().__init__(n_in, n_out, 1, t_len, t_latency=0, recurrent=False, beta_grad=True, adapt=False, init_beta=init_beta, init_p=1, detach_spike_grad=True, surr_grad="fast_sigmoid")
105 | self._block = Block(n_out, t_len, "fast_sigmoid")
106 |
107 | def process(self, x, mode="train"):
108 | pad_current = F.pad(x, pad=(self._t_len - 1, 0)).unsqueeze(1)
109 |
110 | # compute membrane potential without reset
111 | beta_kernel = self._block.build_beta_kernel(self.beta)
112 | membrane = bconv1d(pad_current, beta_kernel)
113 |
114 | return membrane.squeeze(1)
115 |
--------------------------------------------------------------------------------
/src/snn/block/util.py:
--------------------------------------------------------------------------------
1 | import torch
2 | import torch.nn.functional as F
3 |
4 |
5 | def bconv1d(x, weight, stride=1, dilation=1, padding=0):
6 | # Would be useful if PyTorch provided batched 1D convs in their library
7 | b, c, n, h = x.shape
8 | n, out_channels, in_channels, kernel_width_size = weight.shape
9 |
10 | out = x.view(b, c * n, h)
11 | weight = weight.view(n * out_channels, in_channels, kernel_width_size)
12 |
13 | out = F.conv1d(out, weight=weight, bias=None, stride=stride, dilation=dilation, groups=n, padding=padding)
14 |
15 | return out.view(b, c, n, -1)
16 |
17 |
18 | def time_cat(tensor_list, t_pad):
19 | tensor = torch.cat(tensor_list, dim=2)
20 |
21 | if t_pad > 0:
22 | tensor = tensor[:, :, :-t_pad]
23 |
24 | return tensor
25 |
--------------------------------------------------------------------------------
/src/snn/snn.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import torch
3 | import torch.nn as nn
4 | import torch.nn.functional as F
5 | from brainbox.models import BBModel
6 |
7 | from src.snn import surrogate
8 |
9 | # SNN control models.
10 |
11 |
12 | class BaseSNN(BBModel):
13 |
14 | MIN_BETA = 0.001
15 | MAX_BETA = 0.999
16 |
17 | def __init__(self, n_in, n_out, rf_len, t_len, t_latency, recurrent=True, beta_grad=True, adapt=True, init_beta=1, init_p=1, detach_spike_grad=True, surr_grad="fast_sigmoid"):
18 | super().__init__()
19 | self._n_in = n_in
20 | self._n_out = n_out
21 | self._rf_len = rf_len
22 | self._t_len = t_len
23 | self._t_latency = t_latency
24 | self._recurrent = recurrent
25 | self._beta_grad = beta_grad
26 | self._adapt = adapt
27 | self._detach_spike_grad = detach_spike_grad
28 | self._surr_grad = surr_grad
29 |
30 | self._beta = nn.Parameter(data=torch.Tensor(n_out * [init_beta]), requires_grad=beta_grad)
31 | self._rf_weight = nn.Parameter(torch.rand(n_out, 1, n_in, self._rf_len), requires_grad=True)
32 | self._rf_bias = nn.Parameter(torch.zeros(n_out), requires_grad=True)
33 |
34 | self._rec_weight = nn.Parameter(torch.rand(n_out, n_out), requires_grad=recurrent)
35 |
36 | self._p = nn.Parameter(data=torch.Tensor(n_out * [init_p]), requires_grad=adapt)
37 | self._b = nn.Parameter(data=torch.Tensor(n_out * [1.8]), requires_grad=adapt)
38 |
39 | self.init_weight(self._rf_weight, "uniform", a=-1 / np.sqrt(n_in * rf_len), b=1 / np.sqrt(n_in * rf_len))
40 | self.init_weight(self._rec_weight, "identity")
41 |
42 | @property
43 | def hyperparams(self):
44 | return {**super().hyperparams, "n_in": self._n_in, "n_out": self._n_out, "rf_len": self._rf_len, "t_len": self._t_len, "t_latency": self._t_latency, "recurrent": self._recurrent, "beta_grad": self._beta_grad, "adapt": self._adapt, "detach_spike_grad": self._detach_spike_grad, "surr_grad": self._surr_grad}
45 |
46 | @property
47 | def p(self):
48 | return torch.clamp(self._p.abs(), min=0, max=0.999)
49 |
50 | @property
51 | def b(self):
52 | return torch.clamp(self._b.abs(), min=0.001, max=1)
53 |
54 | @property
55 | def beta(self):
56 | return torch.clamp(self._beta, min=BaseSNN.MIN_BETA, max=BaseSNN.MAX_BETA)
57 |
58 | @property
59 | def rec_weight(self):
60 | return self._rec_weight
61 |
62 | def get_rec_input(self, spikes):
63 | return torch.einsum("ij, bj...->bi...", self.rec_weight, spikes.detach() if self._detach_spike_grad else spikes)
64 |
65 | def forward(self, x, mode="train"):
66 | # x: b x n x t
67 |
68 | x = F.pad(x, (self._rf_len - 1, 0))
69 | x = x.unsqueeze(1) # Add channel dim
70 | x = F.conv2d(x, self._rf_weight, self._rf_bias)[:, :, 0] # Slice out height dim
71 |
72 | return self.process(x, mode)
73 |
74 | def process(self, x, mode):
75 | raise NotImplementedError
76 |
77 |
78 | class SNN(BaseSNN):
79 |
80 | def __init__(self, n_in, n_out, rf_len, t_len, t_latency, recurrent=False, beta_grad=True, adapt=True, init_beta=1, init_p=1, detach_spike_grad=True, surr_grad="fast_sigmoid"):
81 | super().__init__(n_in, n_out, rf_len, t_len, t_latency, recurrent, beta_grad, adapt, init_beta, init_p, detach_spike_grad, surr_grad)
82 |
83 | def process(self, x, mode="train"):
84 | # x: b x n x t
85 |
86 | mem_list = []
87 | spikes_list = []
88 | spikes = torch.zeros_like(x).to(x.device)[:, :, 0]
89 | rec_current = torch.zeros_like(x)
90 | mem = torch.zeros_like(x).to(x.device)[:, :, 0]
91 | refac_times = torch.zeros_like(x).to(x.device)[:, :, 0] + self._t_latency
92 |
93 | v_th = torch.ones_like(x).to(x.device)[:, :, 0]
94 | a = torch.zeros_like(x).to(x.device)[:, :, 0]
95 | v_th_list = []
96 |
97 | for t in range(x.shape[2]):
98 | stimulus_current = x[:, :, t]
99 | rec_current[:, :, t] = self.get_rec_input(spikes)
100 |
101 | # Recurrent latency
102 | if t >= self._t_latency and self._recurrent:
103 | input_current = stimulus_current + rec_current[:, :, t-self._t_latency]
104 | else:
105 | input_current = stimulus_current
106 |
107 | # Apply absolute refractory period
108 | refac_times[spikes > 0] = 0
109 | refac_mask = refac_times < self._t_latency
110 | input_current[refac_mask] = 0
111 | refac_times += 1
112 |
113 | new_mem = torch.einsum("bn...,n->bn...", mem, self.beta) + input_current
114 | spikes = surrogate.spike(new_mem - v_th, self._surr_grad)
115 |
116 | mem_list.append(new_mem)
117 | if self._detach_spike_grad:
118 | mem = new_mem * (1 - spikes.detach())
119 | else:
120 | mem = new_mem * (1 - spikes)
121 | # new_mem -= new_mem * spikes (should be same as above?)
122 | spikes_list.append(spikes)
123 |
124 | if self._adapt:
125 | a = self.p * a + spikes
126 | v_th = 1 + self.b * a
127 | v_th_list.append(v_th)
128 |
129 | if mode == "train":
130 | return torch.stack(spikes_list, dim=2)
131 | elif mode == "val":
132 | v_th = torch.stack(v_th_list, dim=2)
133 | v_th = torch.roll(v_th, 1, dims=2)
134 | v_th[:, :, :1] = 1
135 |
136 | return torch.stack(spikes_list, dim=2), torch.stack(mem_list, dim=2), x, v_th
137 |
138 |
139 | class SNNIntegrator(SNN):
140 |
141 | def __init__(self, n_in, n_out, t_len, init_beta=1):
142 | super().__init__(n_in, n_out, 1, t_len, t_latency=0, recurrent=False, beta_grad=True, adapt=False, init_beta=init_beta, init_p=1, detach_spike_grad=True, surr_grad="fast_sigmoid")
143 |
144 | def process(self, x, mode="train"):
145 | mem_list = []
146 | mem = torch.zeros_like(x).to(x.device)[:, :, 0]
147 |
148 | for t in range(x.shape[2]):
149 | input_current = x[:, :, t]
150 |
151 | new_mem = torch.einsum("bn...,n->bn...", mem, self.beta) + input_current
152 | mem_list.append(new_mem)
153 | mem = new_mem
154 |
155 | return torch.stack(mem_list, dim=2)
156 |
--------------------------------------------------------------------------------
/src/snn/surrogate.py:
--------------------------------------------------------------------------------
1 | import math
2 |
3 | import torch
4 |
5 |
6 | class FastSigmoid(torch.autograd.Function):
7 |
8 | @staticmethod
9 | def forward(ctx, input, scale=10):
10 | ctx.scale = scale
11 | ctx.save_for_backward(input)
12 |
13 | return input.gt(0).float()
14 |
15 | @staticmethod
16 | def backward(ctx, grad_output):
17 | input, = ctx.saved_tensors
18 | grad_input = grad_output.clone()
19 | grad = grad_input / (ctx.scale * torch.abs(input) + 1.0) ** 2
20 |
21 | return grad, None
22 |
23 |
24 | class BoxCar(torch.autograd.Function):
25 |
26 | @staticmethod
27 | def forward(ctx, input):
28 | ctx.save_for_backward(input)
29 |
30 | return input.gt(0).float()
31 |
32 | @staticmethod
33 | def backward(ctx, grad_output):
34 | input, = ctx.saved_tensors
35 | grad = grad_output.clone()
36 | grad[input <= -0.5] = 0
37 | grad[input > 0.5] = 0
38 |
39 | return grad
40 |
41 |
42 | class MG(torch.autograd.Function):
43 |
44 | @staticmethod
45 | def forward(ctx, input):
46 | ctx.save_for_backward(input)
47 | return input.gt(0).float()
48 |
49 | @staticmethod
50 | def backward(ctx, grad_output):
51 | input, = ctx.saved_tensors
52 | grad = grad_output.clone()
53 | lens = 0.5
54 | hight = 0.15
55 | scale = 6
56 | gamma = 0.5
57 |
58 | temp = MG.gaussian(input, mu=0., sigma=lens) * (1. + hight) - MG.gaussian(input, mu=lens, sigma=scale * lens) * hight - MG.gaussian(input, mu=-lens, sigma=scale * lens) * hight
59 |
60 | return gamma * grad * temp.float()
61 |
62 | @staticmethod
63 | def gaussian(x, mu=0., sigma=.5):
64 | return torch.exp(-((x - mu) ** 2) / (2 * sigma ** 2)) / torch.sqrt(2 * torch.tensor(math.pi)) / sigma
65 |
66 |
67 | def spike(x, type):
68 | if type == "fast_sigmoid":
69 | return FastSigmoid.apply(x)
70 | elif type == "box_car":
71 | return BoxCar.apply(x)
72 | elif type == "mg":
73 | return MG.apply(x)
74 |
--------------------------------------------------------------------------------
/src/train.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import time
4 | import logging
5 |
6 | import torch
7 | import torch.nn.functional as F
8 | import numpy as np
9 | import pandas as pd
10 | from brainbox import trainer
11 | from brainbox.physiology.spiking import VanRossum
12 |
13 | from src import datasets, models
14 |
15 |
16 | torch.backends.cudnn.benchmark = True
17 | logger = logging.getLogger("trainer")
18 | logging.basicConfig(stream=sys.stdout, level=logging.INFO)
19 |
20 |
21 | class Trainer(trainer.Trainer):
22 |
23 | def __init__(self, root, model, dataset, n_epochs, batch_size, lr, milestones=[-1], gamma=0.1, val_dataset=None, device="cuda", id=None):
24 | super().__init__(root, model, dataset, n_epochs, batch_size, lr, torch.optim.Adam, device=device, optimizer_kwargs={"eps": 1e-5}, loader_kwargs={"shuffle": True, "pin_memory": True, "num_workers": 16}, id=id)
25 | self._milestones = milestones
26 | self._gamma = gamma
27 | self._val_dataset = val_dataset
28 |
29 | self._times = {"forward_pass": [], "backward_pass": []}
30 | self._train_acc = []
31 | self._val_acc = []
32 | self._min_loss = np.inf
33 | self._milestone_idx = 0
34 |
35 | @staticmethod
36 | def accuracy_metric(output, target):
37 | _, predictions = torch.max(output, 1)
38 | return (predictions == target).sum().cpu().item()
39 |
40 | @staticmethod
41 | def spike_count(output, target):
42 | _, cortical_output, thalamic_output = output
43 |
44 | count = cortical_output[0].sum().cpu().item()
45 | count += thalamic_output[0].sum().cpu().item()
46 |
47 | return count
48 |
49 | @property
50 | def times_path(self):
51 | return os.path.join(self.root, self.id, "times.csv")
52 |
53 | @property
54 | def train_acc_path(self):
55 | return os.path.join(self.root, self.id, "train_acc.csv")
56 |
57 | @property
58 | def val_acc_path(self):
59 | return os.path.join(self.root, self.id, "val_acc.csv")
60 |
61 | def save_model_log(self):
62 | super().save_model_log()
63 |
64 | # Save times
65 | times_df = pd.DataFrame(self._times)
66 | times_df.to_csv(self.times_path, index=False)
67 |
68 | # Save acc
69 | train_acc_df = pd.DataFrame(self._train_acc)
70 | train_acc_df.to_csv(self.train_acc_path, index=False)
71 | val_acc_df = pd.DataFrame(self._val_acc)
72 | val_acc_df.to_csv(self.val_acc_path, index=False)
73 |
74 | def loss(self, output, target, model):
75 | target = target.long()
76 | loss = F.cross_entropy(output, target, reduction="mean")
77 |
78 | return loss
79 |
80 | def train_for_single_epoch(self):
81 | epoch_loss = 0
82 | n_samples = 0
83 | n_correct = 0
84 |
85 | for batch_id, (data, target) in enumerate(self.train_data_loader):
86 | data = data.to(self.device).type(self.dtype)
87 | target = target.to(self.device).type(self.dtype)
88 | torch.cuda.synchronize()
89 |
90 | # Forward pass
91 | start_time = time.time()
92 | output = self.model(data)
93 | torch.cuda.synchronize()
94 | forward_pass_time = time.time() - start_time
95 | self._times["forward_pass"].append(forward_pass_time)
96 |
97 | # Compute accuracy
98 | _, predictions = torch.max(output, 1)
99 | n_correct += (predictions == target).sum().cpu().item()
100 |
101 | # Compute loss
102 | loss = self.loss(output, target, self.model)
103 |
104 | # Backward pass
105 | start_time = time.time()
106 | loss.backward()
107 | torch.cuda.synchronize()
108 | backward_pass_time = time.time() - start_time
109 | self._times["backward_pass"].append(backward_pass_time)
110 |
111 | self.optimizer.step()
112 | self.optimizer.zero_grad()
113 |
114 | with torch.no_grad():
115 | epoch_loss += (loss.item() * data.shape[0])
116 | n_samples += data.shape[0]
117 |
118 | train_acc = n_correct/n_samples
119 | logging.info(f"Train acc: {train_acc}")
120 | self._train_acc.append(train_acc)
121 |
122 | if self._val_dataset is not None and len(self.log["train_loss"]) % 5 == 0:
123 | val_acc = Trainer.get_acc(self.model, self._val_dataset, self.batch_size)
124 | logging.info(f"Val acc: {val_acc}")
125 | self._val_acc.append(val_acc)
126 |
127 | return epoch_loss / n_samples
128 |
129 | @staticmethod
130 | def get_acc(model, dataset, batch_size):
131 | scores = trainer.compute_metric(model, dataset, Trainer.accuracy_metric, batch_size=batch_size)
132 | return np.sum(scores) / len(dataset)
133 |
134 | @staticmethod
135 | def get_spike_count(model, dataset, batch_size):
136 | scores = trainer.compute_metric(model, dataset, Trainer.spike_count, batch_size=batch_size)
137 | return np.sum(scores) / len(dataset)
138 |
139 | def on_epoch_complete(self, save):
140 | if save:
141 | self.save_model_log()
142 |
143 | epoch_loss = self.log["train_loss"][-1]
144 | if epoch_loss < self._min_loss:
145 | logging.info(f"Saving model...")
146 | self._min_loss = epoch_loss
147 | self.save_model()
148 |
149 | n_epoch = len(self.log["train_loss"])
150 |
151 | if n_epoch == self._milestones[self._milestone_idx]:
152 | logging.info(f"Decaying lr...")
153 | self.lr *= self._gamma
154 | # Load best model
155 | self.model = Trainer.load_model(self.root, self.id, self.device, self.dtype)
156 | self.optimizer = self.optimizer_func(
157 | self.model.parameters(), self.lr, **self.optimizer_kwargs
158 | )
159 |
160 | if self._milestone_idx != len(self._milestones) - 1:
161 | logging.info(f"New milestone target...")
162 | self._milestone_idx += 1
163 |
164 | def on_training_complete(self, save):
165 | pass
166 |
167 | @staticmethod
168 | def hyperparams_loader(hyperparams):
169 | model_params = hyperparams["model"]
170 | del model_params["name"]
171 | del model_params["weight_initializers"]
172 |
173 | return models.AuditoryModel(**model_params)
174 |
175 | @staticmethod
176 | def load_model(root, id, device="cuda", dtype=torch.float):
177 | return trainer.load_model(root, id, Trainer.hyperparams_loader, device, dtype)
178 |
179 |
180 | class EphysTrainer(Trainer):
181 |
182 | def __init__(self, root, model, dataset, n_epochs, batch_size, lr, gamma=0.1, dt=0.1, epoch_scan=5, max_decay=1, val_dataset=None, device="cuda", id=None):
183 | super().__init__(root, model, dataset, n_epochs, batch_size, lr, [-1], gamma, val_dataset, device, id)
184 | self.epoch_scan = epoch_scan
185 | self.van_rossum = VanRossum(datasets.EphysDataset.LENGTH, tau=100, dt=dt).to(device)
186 | self.max_decay = max_decay
187 |
188 | self._decay_count = 0
189 |
190 | def loss(self, spikes_pred, spikes):
191 | spike_loss = self.van_rossum(spikes_pred, spikes)
192 |
193 | return spike_loss
194 |
195 | def train_for_single_epoch(self):
196 | epoch_loss = 0
197 | n_samples = 0
198 |
199 | for batch_id, (data, target) in enumerate(self.train_data_loader):
200 | data = data.to(self.device).type(self.dtype)
201 | trace = target[0].to(self.device).type(self.dtype)
202 | spikes = target[1].to(self.device).type(self.dtype)
203 | torch.cuda.synchronize()
204 |
205 | # Forward pass
206 | start_time = time.time()
207 | spikes_pred = self.model(data)
208 | torch.cuda.synchronize()
209 | forward_pass_time = time.time() - start_time
210 | self._times["forward_pass"].append(forward_pass_time)
211 |
212 | # Compute loss
213 | loss = self.loss(spikes_pred, spikes)
214 |
215 | # Backward pass
216 | start_time = time.time()
217 | loss.backward()
218 | torch.cuda.synchronize()
219 | backward_pass_time = time.time() - start_time
220 | self._times["backward_pass"].append(backward_pass_time)
221 |
222 | self.optimizer.step()
223 | self.optimizer.zero_grad()
224 |
225 | with torch.no_grad():
226 | epoch_loss += (loss.item() * data.shape[0])
227 | n_samples += data.shape[0]
228 |
229 | return epoch_loss / n_samples
230 |
231 | def on_epoch_complete(self, save):
232 | if save:
233 | self.save_model_log()
234 |
235 | epoch_loss = self.log["train_loss"][-1]
236 | if epoch_loss < self._min_loss:
237 | logging.info(f"Saving model...")
238 | self._min_loss = epoch_loss
239 | self.save_model()
240 |
241 | min_lost_over_last_epochs = np.array(self.log["train_loss"][-self.epoch_scan:]).min()
242 |
243 | if min_lost_over_last_epochs > self._min_loss:
244 | if self._decay_count < self.max_decay:
245 | self._min_loss = np.inf
246 | self._last_train_scores = []
247 | logging.info(f"Decaying lr...")
248 | self._decay_count += 1
249 | self.lr *= self._gamma
250 | # Load best model
251 | self.model = EphysTrainer.load_model(self.root, self.id, self.device, self.dtype)
252 | self.optimizer = self.optimizer_func(
253 | self.model.parameters(), self.lr, **self.optimizer_kwargs
254 | )
255 | else:
256 | self.exit = True
257 |
258 | @staticmethod
259 | def load_model(root, id, device="cuda", dtype=torch.float, dt01ref=False):
260 |
261 | def model_loader(hyperparams):
262 | model_params = hyperparams["model"]
263 | del model_params["name"]
264 | del model_params["weight_initializers"]
265 |
266 | model_params = {**model_params, "dt01ref": dt01ref}
267 |
268 | return models.Neuron(**model_params)
269 |
270 | return trainer.load_model(root, id, model_loader, device, dtype)
271 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webstorms/Blocks/540c9f28eedd58ef638dcacf75b4c27cdf52baa0/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_block.py:
--------------------------------------------------------------------------------
1 | import torch
2 | import pytest
3 |
4 | from src.snn.block.block import Block
5 |
6 |
7 | @pytest.fixture
8 | def block():
9 | return Block(2, 4, "fast_sigmoid")
10 |
11 |
12 | def test_beta_kernel(block):
13 | # Use a single beta
14 | assert torch.allclose(block.build_beta_kernel(torch.Tensor([0.1]))[0, 0, 0], torch.Tensor([0.0010, 0.0100, 0.1000, 1.0000]))
15 | assert torch.allclose(block.build_beta_kernel(torch.Tensor([0.1]))[1, 0, 0], torch.Tensor([0.0010, 0.0100, 0.1000, 1.0000]))
16 |
17 | # Use multiple beta
18 | assert torch.allclose(block.build_beta_kernel(torch.Tensor([0.1]))[0, 0, 0], torch.Tensor([0.0010, 0.0100, 0.1000, 1.0000]))
19 | assert torch.allclose(block.build_beta_kernel(torch.Tensor([0.5]))[1, 0, 0], torch.Tensor([0.1250, 0.2500, 0.5000, 1.0000]))
20 |
21 |
22 | def test_phi_kernel(block):
23 | assert torch.allclose(block._phi_kernel, torch.Tensor([[[[4., 3., 2., 1.]]]]))
24 |
25 |
26 | def test_g(block):
27 | phi_spikes = torch.zeros(2, 4)
28 | phi_spikes[0, 0] = 1
29 | phi_spikes[0, 2] = 2
30 | phi_spikes[1, 1] = 1
31 | phi_spikes[1, 3] = 3
32 | assert block.g(phi_spikes).sum() == 2
33 |
34 |
35 | def test_differentiable_vars(block):
36 | assert not block._beta_ident_base.requires_grad
37 | assert not block._beta_exp.requires_grad
38 | assert not block._phi_kernel.requires_grad
--------------------------------------------------------------------------------
/tests/test_blocks.py:
--------------------------------------------------------------------------------
1 | import torch
2 | import pytest
3 |
4 | from src.snn.snn import SNN
5 | from src.snn.block.blocks import Blocks
6 |
7 |
8 | @pytest.fixture
9 | def in_spikes(b=4, n=100, t=200):
10 | return torch.rand(b, n, t)
11 |
12 |
13 | def get_models(n_in=100, n_out=100, rf_len=10, t_len=200, t_latency=0, recurrent=True, adapt=False):
14 | blocks = Blocks(n_in, n_out, rf_len, t_len, t_latency, recurrent=recurrent, adapt=adapt)
15 | snn = SNN(n_in, n_out, rf_len, t_len, t_latency, recurrent=recurrent, adapt=adapt)
16 |
17 | blocks._rf_weight = snn._rf_weight
18 | blocks._rf_bias = snn._rf_bias
19 | blocks._rec_weight = snn._rec_weight
20 |
21 | return blocks, snn
22 |
23 |
24 | def test_networks_none(in_spikes):
25 | for t_latency in [0, 1, 2, 4, 8]:
26 | blocks, snn = get_models(t_latency=t_latency, recurrent=False)
27 | spikes1 = blocks(in_spikes, mode="train")
28 | spikes2 = snn(in_spikes, mode="train")
29 |
30 | assert torch.allclose(spikes1, spikes2)
31 |
32 |
33 | def test_networks_recurrent(in_spikes):
34 | for t_latency in [0, 1, 2, 4, 8]:
35 | blocks, snn = get_models(t_latency=t_latency, recurrent=True)
36 | spikes1 = blocks(in_spikes, mode="train")
37 | spikes2 = snn(in_spikes, mode="train")
38 |
39 | assert torch.allclose(spikes1, spikes2)
40 |
41 |
42 | def test_adaption(in_spikes):
43 | for t_latency in [0, 1, 2, 4, 8]:
44 | blocks, snn = get_models(t_latency=t_latency, recurrent=True, adapt=True)
45 | spikes1, mem1, x1, z1, v_th1 = blocks(in_spikes, mode="val")
46 | spikes2, mem2, x2, v_th2 = snn(in_spikes, mode="val")
47 |
48 | # v_th should equal at the start of every block
49 | for i in range(blocks._t_len // blocks._t_len_block):
50 | assert torch.allclose(v_th1[:, :, i*blocks._t_len_block], v_th2[:, :, i*blocks._t_len_block])
51 |
--------------------------------------------------------------------------------