From 552c765809e1f983c0ea2a1b7343835e003b5201 Mon Sep 17 00:00:00 2001 From: Riad15l Date: Mon, 25 Sep 2023 15:18:08 +0200 Subject: [PATCH 1/9] REFACTOR: separate KNN from TravelRouting --- .gitignore | 3 + NearestNeighbors/NearestExample.ipynb | 194 ++++++++++++++++++++++++++ NearestNeighbors/knn.py | 88 ++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 .gitignore create mode 100644 NearestNeighbors/NearestExample.ipynb create mode 100644 NearestNeighbors/knn.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66c7298 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +**/__pycache__ +**/.ipynb_checkpoints +**/local_tests \ No newline at end of file diff --git a/NearestNeighbors/NearestExample.ipynb b/NearestNeighbors/NearestExample.ipynb new file mode 100644 index 0000000..69978e3 --- /dev/null +++ b/NearestNeighbors/NearestExample.ipynb @@ -0,0 +1,194 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Nearest Example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Server's Data Setup\n", + "The server owns coordinates to points of interest like restaurants and commerces. The coordinates are kept in a LookupTable" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from concrete import fhe\n", + "import numpy\n", + "\n", + "\n", + "# Database of Points of Interests\n", + "points_array = numpy.array([\n", + " [2, 3], [1, 5], [3, 2], [5, 2], [1, 1],\n", + " [9, 4], [13, 2], [14, 13], [9, 8], [8, 0],\n", + " [2, 10], [3, 8], [8, 12], [4, 10], [7, 7],\n", + "])\n", + "N_PTS = points_array.shape[0]\n", + "points = fhe.LookupTable(points_array.flatten())\n", + "\n", + "\n", + "def get_point(index):\n", + " return (points[2*index], points[2*index + 1])\n", + "\n", + "\n", + "def all_distances(x, y):\n", + " xs = numpy.arange(0, 2 * N_PTS, 2)\n", + " ys = numpy.arange(1, 2 * N_PTS, 2)\n", + " a = abs(points[xs] - x)\n", + " b = abs(points[ys] - y)\n", + " return a + b" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We use swap sort to find the $K$ nearest points to a given point. However, we are interested in the indices of the elements, not just their distances. We must therefore work on tuples of index and distance, effectively implementing numpy argpartition." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# TLUs\n", + "relu = fhe.univariate(lambda x: x if x > 0 else 0)\n", + "is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0)\n", + "arg_selection = fhe.univariate(lambda x: (x-1)//2 if x % 2 else 0) # relu packed with a flag (alternating between 0 and relu)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "def swap(this_idx, this_dist, that_idx, that_dist):\n", + " \"\"\"\n", + " Swaps this and that if this > that. \n", + " We must pass both the index and the distance for both this and that.\n", + "\n", + " Returns:\n", + " idxmin, min, idxmax, max of this and that based on distance\n", + " \"\"\"\n", + " diff = this_dist - that_dist\n", + " idx = arg_selection(2 * (this_idx - that_idx) + is_positive(diff))\n", + " dist = relu(diff)\n", + "\n", + " idx_min = this_idx - idx\n", + " idx_max = that_idx + idx \n", + " dist_min = this_dist - dist\n", + " dist_max = that_dist + dist\n", + " return fhe.array([idx_min, dist_min, idx_max, dist_max])\n", + "\n", + "\n", + "@fhe.compiler({\"x\": \"encrypted\", \"y\": \"encrypted\"})\n", + "def knn(x, y):\n", + " dist = all_distances(x, y)\n", + " idx = list(range(N_PTS))\n", + " for k in range(2):\n", + " for i in range(k+1, N_PTS):\n", + " idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i])\n", + " return fhe.array([get_point(idx[j]) for j in range(2)])\n", + "\n", + "\n", + "inputset = [(4, 3), (0, 0), (15, 3), (4, 15)]\n", + "\n", + "circuit = knn.compile(inputset)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Client\n", + "The client simply invokes the server's nearest neighbours circuit." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "57.9 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" + ] + } + ], + "source": [ + "%%timeit -r 1 -n 1\n", + "circuit.client.keys.generate()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "def nearest(x, y):\n", + " ex, ey = circuit.encrypt(x, y)\n", + " res = circuit.run(ex, ey) # Simulate request to the server\n", + " return circuit.decrypt(res)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Benchmarks" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "21.7 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" + ] + } + ], + "source": [ + "%%timeit -r 1 -n 1\n", + "nearest(4, 3)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "zama", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/NearestNeighbors/knn.py b/NearestNeighbors/knn.py new file mode 100644 index 0000000..5608205 --- /dev/null +++ b/NearestNeighbors/knn.py @@ -0,0 +1,88 @@ +from concrete import fhe +import numpy + + +# TLUs +relu = fhe.univariate(lambda x: x if x > 0 else 0) +is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0) +arg_selection = fhe.univariate(lambda x: (x-1)//2 if x % 2 else 0) + +# Database of Points of Interests +points_array = numpy.array([ + [2, 3], [1, 5], [3, 2], [5, 2], [1, 1], + [9, 4], [13, 2], [14, 13], [9, 8], [8, 0], + [2, 10], [3, 8], [8, 12], [4, 10], [7, 7], +]) +N_PTS = points_array.shape[0] +points = fhe.LookupTable(points_array.flatten()) + + +def get_point(index: int): + return (points[2*index], points[2*index + 1]) + + +def all_distances(x: int, y: int): + """ + Computes distances to all points of interests (POI). + + Arguments: + x, y: coordinates of center + + Returns: + array of distances (int) to POI. + """ + xs = numpy.arange(0, 2 * N_PTS, 2) + ys = numpy.arange(1, 2 * N_PTS, 2) + a = abs(points[xs] - x) + b = abs(points[ys] - y) + return a + b + + +def swap(this_idx, this_dist, that_idx, that_dist): + """ + Swaps this and that if this > that. + We must pass both the index and the distance for both this and that. + + Returns: + idxmin, min, idxmax, max of this and that based on distance + """ + diff = this_dist - that_dist + idx = arg_selection(2 * (this_idx - that_idx) + is_positive(diff)) + dist = relu(diff) + + idx_min = this_idx - idx + idx_max = that_idx + idx + dist_min = this_dist - dist + dist_max = that_dist + dist + return fhe.array([idx_min, dist_min, idx_max, dist_max]) + + +@fhe.compiler({"x": "encrypted", "y": "encrypted"}) +def knn(x, y): + dist = [distance((x, y), get_point(i)) for i in range(N_PTS)] + idx = list(range(N_PTS)) + for k in range(2): + for i in range(k+1, N_PTS): + idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i]) + return fhe.array([get_point(idx[j]) for j in range(2)]) + + +inputset = [(4, 3), (0, 0), (7, 3), (4, 7)] + +circuit = knn.compile(inputset) +circuit.client.keys.generate() + + +def nearest(x, y): + """ + Privately get nearest points of interest (POI). + + Arguments: + x, y: coordinates of the center + + Returns: + Kx2 array of coordinates of neareast POI. + """ + ex, ey = circuit.encrypt(x, y) + res = circuit.run(ex, ey) + return circuit.decrypt(res) \ No newline at end of file From 34cf2e46ae98eb1eb1715a2425b9e0ea10687f83 Mon Sep 17 00:00:00 2001 From: Riad15l Date: Mon, 2 Oct 2023 14:20:35 +0200 Subject: [PATCH 2/9] FEAT: add generate circuit --- .gitignore | 2 +- NearestNeighbors/config.py | 8 + NearestNeighbors/data/restaurants.geojson | 4618 +++++++++++++++++++++ NearestNeighbors/generate_circuit.py | 73 + NearestNeighbors/network.py | 17 + 5 files changed, 4717 insertions(+), 1 deletion(-) create mode 100644 NearestNeighbors/config.py create mode 100644 NearestNeighbors/data/restaurants.geojson create mode 100644 NearestNeighbors/generate_circuit.py create mode 100644 NearestNeighbors/network.py diff --git a/.gitignore b/.gitignore index 66c7298..40d66a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ **/__pycache__ **/.ipynb_checkpoints -**/local_tests \ No newline at end of file +**/local_tests** \ No newline at end of file diff --git a/NearestNeighbors/config.py b/NearestNeighbors/config.py new file mode 100644 index 0000000..8f1cb5e --- /dev/null +++ b/NearestNeighbors/config.py @@ -0,0 +1,8 @@ +from pathlib import Path + +data_path = Path(__file__).parent / "data" +restaurants_filepath = data_path / "restaurants.geojson" +circuit_filepath = data_path / "circuit.zip" +keys_filepath = data_path / "keys.zip" +total_restaurants_number = 15 +number_of_neighbors = 3 \ No newline at end of file diff --git a/NearestNeighbors/data/restaurants.geojson b/NearestNeighbors/data/restaurants.geojson new file mode 100644 index 0000000..449d456 --- /dev/null +++ b/NearestNeighbors/data/restaurants.geojson @@ -0,0 +1,4618 @@ +{ + "type": "FeatureCollection", + "generator": "overpass-ide", + "copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.", + "timestamp": "2023-09-25T15:05:05Z", + "features": [ + { + "type": "Feature", + "properties": { + "@id": "node/287612106", + "amenity": "restaurant", + "name": "Vadrouille" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3373832, + 48.8773406 + ] + }, + "id": "node/287612106" + }, + { + "type": "Feature", + "properties": { + "@id": "node/428141439", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "22", + "contact:phone": "+33 1 48 00 07 73", + "contact:postcode": "75009", + "contact:street": "Rue de Trévise", + "cuisine": "chinese", + "description": "Spécialités du Hunan", + "indoor_seating": "yes", + "name": "L'Orient d'Or", + "name:fr": "L'Orient d'Or", + "name:zh": "福源丰", + "opening_hours": "Tu-Su 12:00-14:30,19:00-22:30; Mo off", + "outdoor_seating": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3454201, + 48.8739244 + ] + }, + "id": "node/428141439" + }, + { + "type": "Feature", + "properties": { + "@id": "node/663314810", + "amenity": "restaurant", + "name": "La Rimaudière", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3394255, + 48.8768016 + ] + }, + "id": "node/663314810" + }, + { + "type": "Feature", + "properties": { + "@id": "node/971476013", + "amenity": "restaurant", + "name": "Les Ailes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3443375, + 48.8739835 + ] + }, + "id": "node/971476013" + }, + { + "type": "Feature", + "properties": { + "@id": "node/971476045", + "amenity": "restaurant", + "cuisine": "chinese", + "name": "Hong Kong" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3456817, + 48.876213 + ] + }, + "id": "node/971476045" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1232772691", + "addr:city": "Paris", + "addr:housenumber": "46", + "addr:postcode": "75009", + "addr:street": "Rue La Fayette", + "amenity": "restaurant", + "contact:phone": "+33 1 73 77 82 66", + "contact:website": "https://www.kozy.fr/kozy-kanope-brunch", + "cuisine": "cake;coffee_shop;french;juice;pancake;brunch", + "delivery": "yes", + "diet:vegetarian": "yes", + "kids_area": "no", + "level": "0", + "microbrewery": "no", + "name": "Kozy Kanope", + "opening_hours": "Mo-Fr 08:00-15:00; Sa-Su 09:30-16:00", + "outdoor_seating": "no", + "reservation": "no", + "self_service": "no", + "smoking": "no", + "takeaway": "yes", + "toilets": "yes", + "toilets:access": "customers", + "toilets:wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3401877, + 48.8747844 + ] + }, + "id": "node/1232772691" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1236456908", + "addr:housenumber": "23", + "addr:postcode": "75009", + "addr:street": "Rue Lamartine", + "amenity": "restaurant", + "contact:instagram": "piknik_paris", + "cuisine": "asian", + "name": "Piknik", + "name:ko": "피크닉", + "opening_hours": "Mo-Fr 12:00-14:30", + "source": "cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2011" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3415422, + 48.8764927 + ] + }, + "id": "node/1236456908" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1242438647", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "35", + "contact:postcode": "75009", + "contact:street": "Rue de Trévise", + "cuisine": "mexican", + "name": "Black bean", + "name:fr": "Black beans Mexicain", + "source": "cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2011" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.345311, + 48.8748766 + ] + }, + "id": "node/1242438647" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1242439149", + "addr:housenumber": "58", + "addr:street": "Rue La Fayette", + "amenity": "restaurant", + "name": "L'Atelier Saisonnier", + "source": "cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2011" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3424351, + 48.8753894 + ] + }, + "id": "node/1242439149" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1242504317", + "addr:housenumber": "16", + "amenity": "restaurant", + "name": "A la", + "name:fr": "Grange beteliere" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3412456, + 48.8732347 + ] + }, + "id": "node/1242504317" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1242504439", + "addr:housenumber": "12", + "addr:postcode": "75009", + "addr:street": "Rue Buffault", + "amenity": "restaurant", + "cuisine": "sandwich", + "internet_access": "wlan", + "name": "Les Bariolés de Maud", + "opening_hours": "Mo-Fr 11:00-15:00; Sa,Su 10:00-16:00", + "phone": "+33 9 73 17 01 68", + "source": "cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2011", + "website": "http://lesbariolesdemaud.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3419536, + 48.8755706 + ] + }, + "id": "node/1242504439" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1293504445", + "addr:housenumber": "77", + "addr:postcode": "75009", + "addr:street": "Rue Taitbout", + "amenity": "restaurant", + "changing_table": "no", + "cuisine": "asian;korean;japanese", + "indoor_seating": "yes", + "level": "0", + "name": "Dolsotbat", + "opening_hours": "Mo-Sa 12:00-14:30, 19:00-22:30", + "outdoor_seating": "no", + "phone": "+33 1 48 78 47 44", + "smoking": "no", + "source": "survey", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes", + "toilets": "yes", + "toilets:access": "customers", + "toilets:disposal": "flush", + "toilets:position": "seated", + "toilets:wheelchair": "no", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3352931, + 48.8765206 + ] + }, + "id": "node/1293504445" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1293504454", + "amenity": "restaurant", + "contact:housenumber": "43", + "contact:street": "Rue Saint-Lazare", + "cuisine": "portuguese;petiscos;bacalhau", + "diet:vegetarian": "no", + "indoor_seating": "yes", + "name": "CDP Paris Lazare", + "opening_hours": "Mo-Sa 12:00-00:00", + "outdoor_seating": "yes", + "phone": "+33 1 48 74 32 94", + "short_name": "CDP Lazare", + "strapline": "Cuisine Lusitanienne by Alfredo Martins", + "website": "https://cdp-paris.fr/fr/restaurants-portugais-de-qualite-by-alfredo-martins", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3353158, + 48.876753 + ] + }, + "id": "node/1293504454" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1293504460", + "addr:housenumber": "75", + "addr:street": "Rue Taitbout", + "amenity": "restaurant", + "cuisine": "pasta", + "description": "Small pasta-oriented restaurant / take-away", + "name": "Pasta & Co" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3352844, + 48.8764639 + ] + }, + "id": "node/1293504460" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1293504477", + "addr:city": "Paris", + "addr:housenumber": "75", + "addr:postcode": "75009", + "addr:street": "Rue Taitbout", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Angkor Maison", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3352774, + 48.8764321 + ] + }, + "id": "node/1293504477" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1294579480", + "amenity": "restaurant", + "cuisine": "regional", + "name": "Café Lorette", + "outdoor_seating": "yes", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3383718, + 48.8760923 + ] + }, + "id": "node/1294579480" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1294579482", + "addr:postcode": "75009", + "addr:street": "Rue La Fayette", + "amenity": "restaurant", + "changing_table": "no", + "cuisine": "chinese", + "level": "0", + "microbrewery": "no", + "name": "Sucrépice", + "opening_hours": "Tu-Su 12:00-22:30", + "outdoor_seating": "no", + "self_service": "no", + "smoking": "no", + "stars": "0", + "toilets": "yes", + "toilets:access": "customers", + "toilets:disposal": "flush", + "toilets:position": "seated", + "toilets:wheelchair": "no", + "wheelchair": "limited" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3383294, + 48.8745269 + ] + }, + "id": "node/1294579482" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1294579501", + "amenity": "restaurant", + "cuisine": "french", + "name": "Le Laffitte", + "phone": "+33 1 42 80 07 66", + "smoking": "no", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.338336, + 48.8751689 + ] + }, + "id": "node/1294579501" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1440250986", + "amenity": "restaurant", + "cuisine": "french", + "name": "Chez Vous", + "smoking": "separated", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3398701, + 48.8776662 + ] + }, + "id": "node/1440250986" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1445872328", + "amenity": "restaurant", + "cuisine": "french", + "name": "Les Demoiselles de Lorette", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3370142, + 48.8764828 + ] + }, + "id": "node/1445872328" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1445872334", + "amenity": "restaurant", + "cuisine": "italian", + "name": "Salsamenteria di Parma", + "source": "survey 2017" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3371884, + 48.8765216 + ] + }, + "id": "node/1445872334" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1482958090", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "34", + "contact:postcode": "75009", + "contact:street": "Rue de la Victoire", + "contact:website": "https://www.memento-paris.fr", + "cuisine": "traditional;french", + "name": "Memento", + "opening_hours": "Mo-Fr 12:00-14:30", + "smoking": "no", + "source": "survey", + "takeaway": "yes", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3376319, + 48.875193 + ] + }, + "id": "node/1482958090" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1482958091", + "amenity": "restaurant", + "cuisine": "regional", + "name": "Le Chantereine", + "phone": "+33 1 48 74 49 86", + "source": "survey", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3355442, + 48.8751468 + ] + }, + "id": "node/1482958091" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1482958093", + "amenity": "restaurant", + "cuisine": "chinese", + "fax": "+33 9 50 55 66 68", + "internet_access": "wlan", + "name": "Paradis des Pâtes", + "opening_hours": "Mo-Sa 11:00-23:00", + "smoking": "no", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3392304, + 48.8750364 + ] + }, + "id": "node/1482958093" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1483017712", + "addr:city": "Paris", + "addr:housenumber": "15", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "cuisine": "korean", + "name": "Bib!mbar", + "source": "survey", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3373282, + 48.8766742 + ] + }, + "id": "node/1483017712" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1483017713", + "addr:city": "Paris", + "addr:housenumber": "21", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "capacity": "60", + "cuisine": "french;bistro", + "email": "bonjour@mieux-restaurant.com", + "name": "Mieux", + "opening_hours": "Mo-Sa 12:00-14:30,19:30-22:30", + "phone": "+33 1 71 32 46 73", + "smoking": "no", + "takeaway": "no", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes", + "website": "http://mieux-restaurant.com/", + "wheelchair": "limited" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3368624, + 48.8766497 + ] + }, + "id": "node/1483017713" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1483017719", + "amenity": "restaurant", + "cuisine": "french", + "name": "Jean", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3383704, + 48.8769295 + ] + }, + "id": "node/1483017719" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1483017723", + "amenity": "restaurant", + "cuisine": "argentinian", + "name": "Pony Polo", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3369941, + 48.8767991 + ] + }, + "id": "node/1483017723" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1483017727", + "amenity": "restaurant", + "cuisine": "indian", + "name": "Mamtajmahal", + "smoking": "no", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.336497, + 48.8768072 + ] + }, + "id": "node/1483017727" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1483017732", + "amenity": "restaurant", + "brand": "Bistro Régent", + "cuisine": "steak_house;french", + "name": "Bistro Régent", + "smoking": "no", + "source": "survey", + "wheelchair": "limited" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3388379, + 48.875855 + ] + }, + "id": "node/1483017732" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1483017733", + "amenity": "restaurant", + "cuisine": "japanese", + "name": "Yokhama", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3367754, + 48.8767897 + ] + }, + "id": "node/1483017733" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1483017735", + "amenity": "restaurant", + "cuisine": "japanese", + "name": "Sakura", + "opening_hours": "Mo-Sa 11:30-15:00,18:30-22:30; Su 18:30-22:30", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3379529, + 48.8767676 + ] + }, + "id": "node/1483017735" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1489467283", + "amenity": "restaurant", + "name": "Gontran", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3393542, + 48.8749956 + ] + }, + "id": "node/1489467283" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1675451555", + "amenity": "restaurant", + "check_date": "2023-08-06", + "contact:city": "Paris", + "contact:housenumber": "25", + "contact:postcode": "75009", + "contact:street": "Rue Le Peletier", + "contact:website": "https://www.restaurant-aupetitriche.com/", + "cuisine": "french", + "name": "Au Petit Riche" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.33857, + 48.8731527 + ] + }, + "id": "node/1675451555" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1677167520", + "amenity": "restaurant", + "cuisine": "pizza", + "level": "0", + "name": "Pizza Capri", + "outdoor_seating": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.343092, + 48.8739879 + ] + }, + "id": "node/1677167520" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1824826034", + "amenity": "restaurant", + "cuisine": "french", + "name": "Saveurs et Coïncedences" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3451791, + 48.8728885 + ] + }, + "id": "node/1824826034" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1827138878", + "amenity": "restaurant", + "cuisine": "chinese", + "name": "Bao nan", + "smoking": "no", + "wheelchair": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3394762, + 48.8765391 + ] + }, + "id": "node/1827138878" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1827138887", + "amenity": "restaurant", + "cuisine": "japanese", + "name": "Kamado", + "phone": "+33 1 42 80 39 92" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3400961, + 48.8762148 + ] + }, + "id": "node/1827138887" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1827138888", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Magokoro", + "smoking": "no", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3406889, + 48.8765114 + ] + }, + "id": "node/1827138888" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1827138894", + "amenity": "restaurant", + "cuisine": "regional", + "email": "info@restaurant-les-saisons.com", + "name": "Les Saisons", + "opening_hours": "12:00-14:30,19:15-22:45", + "phone": "+33 1 40 16 08 00", + "website": "https://www.restaurant-les-saisons.com/", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3401427, + 48.8766574 + ] + }, + "id": "node/1827138894" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1827138908", + "amenity": "restaurant", + "cuisine": "korean", + "name": "Sambuja", + "phone": "+33 1 53 21 07 89" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3395753, + 48.876475 + ] + }, + "id": "node/1827138908" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1827138910", + "addr:housenumber": "44", + "addr:street": "Rue Lamartine", + "amenity": "restaurant", + "cuisine": "chinese", + "name": "Xi'an", + "opening_hours": "Mo-Sa 11:45-14:45, 18:30-22:30", + "phone": "+33 1 42 81 38 07", + "smoking": "outside", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3408729, + 48.8766336 + ] + }, + "id": "node/1827138910" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1827138913", + "amenity": "restaurant", + "cuisine": "french", + "name": "Le Milton", + "opening_hours": "Mo-Sa 10:00-19:00", + "operator": "Mme Epale Sophie", + "phone": "+33 1 45 26 62 20", + "smoking": "outside", + "website": "https://fr-fr.facebook.com/Les-madeleines-parisiennes-343278205695571/", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3405656, + 48.8766574 + ] + }, + "id": "node/1827138913" + }, + { + "type": "Feature", + "properties": { + "@id": "node/1980664953", + "amenity": "restaurant", + "name": "La Boule Rouge" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3445761, + 48.8731487 + ] + }, + "id": "node/1980664953" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2103216535", + "amenity": "restaurant", + "name": "Chez Yanina", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3429609, + 48.8727963 + ] + }, + "id": "node/2103216535" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2103216538", + "amenity": "restaurant", + "name": "Ravioli Nord-Est", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3429812, + 48.8727029 + ] + }, + "id": "node/2103216538" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2126361721", + "addr:city": "Paris", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "cuisine": "pizza", + "name": "Matteo", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3432485, + 48.8729655 + ] + }, + "id": "node/2126361721" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2164023035", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Le Bonheur", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3435127, + 48.8730359 + ] + }, + "id": "node/2164023035" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2446448987", + "amenity": "restaurant", + "check_date": "2023-01-26", + "cuisine": "korean", + "name": "Sobane", + "opening_hours": "Mo-Sa 12:00-14:30, Mo-Su 19:00-22:30", + "smoking": "outside" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3448931, + 48.8788007 + ] + }, + "id": "node/2446448987" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2471187815", + "amenity": "restaurant", + "cuisine": "turkish", + "name": "Grill Istanbul", + "source": "survey 2013", + "survey:date": "2018-05-05" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3442886, + 48.8767035 + ] + }, + "id": "node/2471187815" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2485354450", + "amenity": "restaurant", + "name": "Jeanne-Aimée" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3384716, + 48.8762503 + ] + }, + "id": "node/2485354450" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2500457603", + "amenity": "restaurant", + "check_date:opening_hours": "2022-09-14", + "name": "Au Taquet", + "opening_hours:signed": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3459265, + 48.8758373 + ] + }, + "id": "node/2500457603" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2514493568", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Traiteur Délice Cadet", + "phone": "+33148784192", + "source": "survey", + "survey:date": "2018-05-05" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3443424, + 48.8763927 + ] + }, + "id": "node/2514493568" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2680421252", + "addr:city": "Paris", + "addr:housenumber": "46", + "addr:postcode": "75009", + "addr:street": "Rue Richer", + "air_conditioning": "yes", + "amenity": "restaurant", + "cuisine": "pizza;pasta", + "delivery": "yes", + "description": "Situé à Paris 9, à quelques mètres des Grands Boulevards, PAPI est un restaurant italien spécialisé dans la conception de pizzas au levain. Le restaurant PAPI (Pasta Pizza) vous propose des pizzas à base de levain, pâtes fraîches artisanales et aussi, de", + "diet:vegetarian": "yes", + "email": "bonjour@papirestaurant.fr", + "name": "Papi Restaurant", + "opening_hours": "Mo-Tu,Sa 12:00-14:00,18:30-21:30", + "phone": "+33 1 71 27 77 65", + "reservation": "yes", + "takeaway": "yes", + "website": "https://www.papirestaurant.fr/", + "wheelchair": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3436798, + 48.8741108 + ] + }, + "id": "node/2680421252" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2692650140", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "17", + "contact:postcode": "75009", + "contact:street": "Rue de Trévise", + "cuisine": "ukrainian;russian", + "diet:vegetarian": "yes", + "name": "Kalinka", + "wheelchair": "limited" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3451402, + 48.8737471 + ] + }, + "id": "node/2692650140" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2692650157", + "amenity": "restaurant", + "cuisine": "regional", + "name": "Kolfe Jean", + "opening_hours": "Mo-Fr 12:00-15:00,19:00-22:00", + "phone": "+33 1 47 70 68 76", + "smoking": "outside" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3449978, + 48.8731623 + ] + }, + "id": "node/2692650157" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2692650176", + "amenity": "restaurant", + "check_date": "2023-07-05", + "cuisine": "pizza", + "delivery": "no", + "diet:vegetarian": "yes", + "name": "Le Bookie", + "outdoor_seating": "yes", + "payment:mastercard": "yes", + "payment:visa": "yes", + "reservation": "yes", + "takeaway": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3450913, + 48.8735309 + ] + }, + "id": "node/2692650176" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2799009841", + "addr:housenumber": "46", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "cuisine": "chinese", + "name": "Les Pâtes Vivantes", + "name:zh": "活着的面条", + "opening_hours": "Mo-Fr 12:00-15:00; Sa-Su 12:00-15:30; Mo-Su 19:00-23:00", + "phone": "+33 145 231 021", + "source": "survey 2013", + "website": "https://www.lespatesvivantes.net/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3415841, + 48.8747214 + ] + }, + "id": "node/2799009841" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2799010416", + "amenity": "restaurant", + "cuisine": "asian", + "source": "survey 2013" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.341641, + 48.874684 + ] + }, + "id": "node/2799010416" + }, + { + "type": "Feature", + "properties": { + "@id": "node/2961160024", + "addr:city": "Paris", + "addr:housenumber": "24", + "addr:postcode": "75009", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "capacity": "8", + "cuisine": "pizza;new_york_pizza", + "facebook": "https://facebook.com/nickspizza", + "name": "Nick's Pizza", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3430215, + 48.8734806 + ] + }, + "id": "node/2961160024" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3183025906", + "amenity": "restaurant", + "cuisine": "chinese;huoguo", + "name": "Les Trois Royaumes", + "name:zh": "諸葛烤魚", + "source": "cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2011" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3439946, + 48.8741151 + ] + }, + "id": "node/3183025906" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3415271563", + "amenity": "restaurant", + "cuisine": "Chinese;Shanxi", + "name": "Restaurant Do Eat", + "opening_hours": "Tu-We 12:00-14:30,19:00-22:30", + "phone": "+33 9 52 67 26 57", + "takeaway": "yes", + "website": "https://www.doeatparis.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3419096, + 48.8764622 + ] + }, + "id": "node/3415271563" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3415271564", + "addr:city": "Paris", + "addr:housenumber": "28", + "addr:postcode": "75009", + "addr:street": "Rue Lamartine", + "amenity": "restaurant", + "cuisine": "korean", + "name": "Gin Go Gae", + "name:fr": "Gin Go Gae" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3419539, + 48.8766024 + ] + }, + "id": "node/3415271564" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3438053658", + "amenity": "restaurant", + "cuisine": "japanese", + "name": "Fukushiyama", + "opening_hours": "Mo-Fr 12:00-15:00,18:30-23:00; Su 18:30-23:00", + "operator": "Shao Jean-Marc", + "phone": "+33 1 47 70 45 67", + "smoking": "outside", + "source": "survey:2015-03-28", + "takeaway": "yes", + "toilets:wheelchair": "no", + "website": "http://www.fukushiyama-75.fr/", + "wheelchair": "limited", + "wheelchair:description": "Petite marche à l'entrée, porte battante difficile à ouvrir. Toilettes en bas d'un escalier très étroit et raide." + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3445995, + 48.8759307 + ] + }, + "id": "node/3438053658" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3438081754", + "amenity": "restaurant", + "name": "La Gargamelle", + "outdoor_seating": "yes", + "phone": "+33 1 48 78 33 90" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3400064, + 48.8749633 + ] + }, + "id": "node/3438081754" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3535132540", + "amenity": "restaurant", + "brewery": "Estrella;Voll Damm", + "cuisine": "spanish", + "name": "Le Petit Barcelone" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3449321, + 48.873689 + ] + }, + "id": "node/3535132540" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3564245493", + "amenity": "restaurant", + "name": "Les Fils à Maman", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3437357, + 48.8735029 + ] + }, + "id": "node/3564245493" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3564245494", + "amenity": "restaurant", + "cuisine": "italian", + "description": "Caffé della Pizza Ristorante", + "name": "Caffé della Pizza", + "note:fr": "Caffé della Pizza Ristorante" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3433422, + 48.8733106 + ] + }, + "id": "node/3564245494" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3564245498", + "addr:housenumber": "7", + "addr:postcode": "75009", + "addr:street": "Rue Geoffroy Marie", + "amenity": "restaurant", + "cuisine": "italian", + "name": "Vale & Ale", + "opening_hours": "Mo-Su 12:00-14:00, 19:30-22:00", + "phone": "+33153346287", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3436472, + 48.8734527 + ] + }, + "id": "node/3564245498" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3588432323", + "amenity": "restaurant", + "cuisine": "chinese", + "name": "La Cuisine Chinoise", + "phone": "+33147702227", + "source": "survey 2015" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3444855, + 48.8751609 + ] + }, + "id": "node/3588432323" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3655998026", + "amenity": "restaurant", + "name": "Le Beaucé" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3435445, + 48.8739692 + ] + }, + "id": "node/3655998026" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3668191709", + "amenity": "restaurant", + "name": "Le Bistrot à deux Têtes", + "phone": "+33 1 48 78 35 58" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3403004, + 48.8756987 + ] + }, + "id": "node/3668191709" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3696663374", + "amenity": "restaurant", + "name": "Etoile du Liban", + "takeaway": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3384166, + 48.8776751 + ] + }, + "id": "node/3696663374" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3702655129", + "access:covid19": "no", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "25", + "contact:postcode": "75009", + "contact:street": "Rue Taitbout", + "cuisine": "indian", + "delivery:covid19": "yes", + "diet:vegan": "yes", + "diet:vegetarian": "yes", + "name": "New Balal" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.33519, + 48.8732905 + ] + }, + "id": "node/3702655129" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3811446658", + "amenity": "restaurant", + "cuisine": "french", + "name": "À Côté" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3350103, + 48.8734176 + ] + }, + "id": "node/3811446658" + }, + { + "type": "Feature", + "properties": { + "@id": "node/3837126776", + "addr:postcode": "75009", + "amenity": "restaurant", + "name": "Arlette", + "opening_hours": "Tu-Fr 12:00-14:30,19:00-22:00; Sa 12:00-14:30,19:00-22:30", + "operator": "Daskin Hakan", + "phone": "+33 9 80 59 21 67", + "smoking": "outside", + "website": "https://www.facebook.com/La-Pom-dAmour-746638862110531" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3409528, + 48.8766269 + ] + }, + "id": "node/3837126776" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4034254547", + "addr:housenumber": "70", + "addr:postcode": "75009", + "addr:street": "Rue La Fayette", + "amenity": "restaurant", + "happy_hours": "Mo-Su 16:00-22:00", + "name": "Le Régent", + "opening_hours": "Mo-Su 12:00-02:00", + "phone": "+33 1 48 78 14 69" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3441556, + 48.8758287 + ] + }, + "id": "node/4034254547" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4044332041", + "amenity": "restaurant", + "check_date": "2023-08-06", + "contact:city": "Paris", + "contact:housenumber": "21", + "contact:phone": "+33 9 83 87 95 95", + "contact:postcode": "75009", + "contact:street": "Rue Le Peletier", + "contact:website": "https://www.monph7.com/", + "diet:vegetarian": "only", + "gluten_free": "yes", + "name": "PH7", + "organic": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.338495, + 48.8729555 + ] + }, + "id": "node/4044332041" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4044482741", + "amenity": "restaurant", + "indoor_seating": "yes", + "name": "Xia ri leng yin", + "outdoor_seating": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3360738, + 48.8742719 + ] + }, + "id": "node/4044482741" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4125470311", + "amenity": "restaurant", + "contact:phone": "+33 9 50 77 53 35", + "cuisine": "chinese", + "name": "Aigle d'Orient", + "website": "https://www.aigledorient.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3444061, + 48.8769794 + ] + }, + "id": "node/4125470311" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4125470312", + "amenity": "restaurant", + "contact:phone": "+33 1 48 78 09 95", + "cuisine": "japanese", + "name": "Yoki", + "opening_hours": "18:00-23:00, Mo-Sa 10:30-14:30", + "website": "https://yokisushi.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3446736, + 48.877117 + ] + }, + "id": "node/4125470312" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4125480963", + "amenity": "restaurant", + "cuisine": "lebanese", + "name": "Al Bayader", + "phone": "+33 1 48 78 40 44" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3445038, + 48.8772828 + ] + }, + "id": "node/4125480963" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4143268694", + "addr:city": "Paris", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Moy Goi Cuon Bar", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3361925, + 48.8768325 + ] + }, + "id": "node/4143268694" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4143268696", + "addr:city": "Paris", + "addr:housenumber": "32", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "name": "Barth", + "takeaway": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3362388, + 48.8768334 + ] + }, + "id": "node/4143268696" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4197369297", + "amenity": "restaurant", + "cuisine": "regional", + "name": "Plein Ouest" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3443814, + 48.8743663 + ] + }, + "id": "node/4197369297" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4207592630", + "addr:city": "Paris", + "addr:housenumber": "11", + "addr:postcode": "75009", + "addr:street": "Rue de Montyon", + "amenity": "restaurant", + "cuisine": "japanese", + "description": "Restaurant japonais gastronomique", + "name": "Sumibi Kaz", + "opening_hours": "Tu-Sa 12:00-14:30, 19:00-23:00", + "phone": "+33 1 45 80 26 98", + "website": "https://www.sumibi-kaz.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3441219, + 48.8730386 + ] + }, + "id": "node/4207592630" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4207596442", + "amenity": "restaurant", + "cuisine": "japanese", + "name": "Matsusaka" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3438082, + 48.8731589 + ] + }, + "id": "node/4207596442" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4278261392", + "addr:housenumber": "8", + "addr:postcode": "75009", + "addr:street": "Rue Geoffroy Marie", + "amenity": "restaurant", + "cuisine": "mexican", + "name": "Zicatela" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3440632, + 48.8734583 + ] + }, + "id": "node/4278261392" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4278626563", + "amenity": "restaurant", + "diet:kosher": "only", + "name": "Chez David", + "phone": "+33 1 40 22 61 05", + "smoking": "outside" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3442623, + 48.8730373 + ] + }, + "id": "node/4278626563" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4278626564", + "addr:city": "Paris", + "addr:housenumber": "11", + "addr:postcode": "75009", + "addr:street": "Rue de Montyon", + "amenity": "restaurant", + "cuisine": "thai", + "name": "Makham Thaï Paris", + "phone": "+33 1 47 70 40 95" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3439214, + 48.8730409 + ] + }, + "id": "node/4278626564" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4312773166", + "addr:city": "Paris", + "addr:housenumber": "47", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Georges", + "amenity": "restaurant", + "cuisine": "turkish", + "name": "Sizin", + "takeaway": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3372674, + 48.8778223 + ] + }, + "id": "node/4312773166" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4360249193", + "addr:housenumber": "21", + "addr:postcode": "75009", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "check_date": "2023-08-06", + "contact:facebook": "mamieburgerparis", + "contact:instagram": "mamierestaurants", + "contact:twitter": "mamieburger", + "cuisine": "burger", + "name": "Mamie", + "name:fr": "Mamia Burger", + "opening_hours": "Mo-Su 08:00-15:00, 18:00-02:00", + "phone": "+33 9 81 45 72 92", + "website": "https://www.mamie-restaurants.com/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3429045, + 48.8730594 + ] + }, + "id": "node/4360249193" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4366842297", + "addr:housenumber": "3", + "addr:street": "Rue Milton", + "amenity": "restaurant", + "cuisine": "brazilian", + "name": "Gabriela", + "name:fr": "Gabriela", + "phone": "+33 1 42 80 28 14", + "website": "http://www.gabriela.fr/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3404516, + 48.8770327 + ] + }, + "id": "node/4366842297" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4389641402", + "addr:city": "Paris", + "addr:housenumber": "18", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "capacity": "20", + "cuisine": "french", + "description": "Restaurant bar à vin", + "drink": "wine", + "level": "1", + "name": "Le Bouclier de Bacchus", + "payment:credit_cards": "yes", + "smoking": "outside", + "website": "http://www.bouclierdebacchus.com/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3377035, + 48.8768607 + ] + }, + "id": "node/4389641402" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4412152990", + "amenity": "restaurant", + "changing_table": "no", + "cuisine": "japanese;noodle", + "level": "0", + "name": "Abri Soba", + "name:fr": "Abri Soba", + "outdoor_seating": "no", + "reservation": "no", + "smoking": "no", + "toilets": "yes", + "toilets:access": "customers", + "toilets:disposal": "flush", + "toilets:position": "seated", + "toilets:wheelchair": "no", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3444884, + 48.8750272 + ] + }, + "id": "node/4412152990" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4475551548", + "addr:housenumber": "4", + "addr:street": "Rue Choron", + "amenity": "restaurant", + "name": "Choron", + "opening_hours:covid19": "off" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.342466, + 48.8773651 + ] + }, + "id": "node/4475551548" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4548570150", + "addr:housenumber": "24", + "addr:postcode": "75009", + "addr:street": "Rue Richer", + "amenity": "restaurant", + "cuisine": "indonesian", + "name": "Makan Makan", + "opening_hours": "Mo-Fr 12:00-14:30, 18:30-22:00; Sa 12:00-15:00", + "phone": "+33 6 27 06 97 01", + "source": "survey" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3458145, + 48.8741173 + ] + }, + "id": "node/4548570150" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4580960891", + "addr:housenumber": "60", + "addr:postcode": "75009", + "addr:street": "Rue La Fayette", + "amenity": "restaurant", + "cuisine": "french", + "name": "Les Diamantaires", + "phone": "+33147707814" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3425926, + 48.8754075 + ] + }, + "id": "node/4580960891" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4678487389", + "amenity": "restaurant", + "cuisine": "vietnamese", + "name": "Mô Ri" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3404799, + 48.8755603 + ] + }, + "id": "node/4678487389" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4680160490", + "addr:city": "Paris 9eme Arrondissement", + "addr:housenumber": "17", + "addr:postcode": "75009", + "addr:street": "Rue de Maubeuge", + "amenity": "restaurant", + "contact:facebook": "https://www.facebook.com/CoinOpTable", + "contact:twitter": "https://twitter.com/COINOPTABLE", + "cuisine": "burger;coffee_shop;french", + "diet:vegetarian": "yes", + "leisure": "amusement_arcade", + "name": "Coin-Op Table", + "opening_hours": "We-Sa 12:00-23:00, Tu 12:00-15:00, Su 12:00-17:00", + "phone": "+33140358537", + "website": "http://coin-op-table.com/", + "wheelchair": "limited" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3434569, + 48.8777223 + ] + }, + "id": "node/4680160490" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4753837525", + "amenity": "restaurant", + "cuisine": "italian", + "name": "Moriarty", + "name:fr": "Moriarty" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3421848, + 48.8766059 + ] + }, + "id": "node/4753837525" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4758384028", + "amenity": "restaurant", + "level": "0", + "name": "Fuxia", + "name:fr": "Fuxia" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3395866, + 48.8784544 + ] + }, + "id": "node/4758384028" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4790815426", + "addr:city": "Paris", + "addr:housenumber": "47", + "addr:postcode": "75009", + "addr:street": "Rue Richer", + "amenity": "restaurant", + "level": "0", + "name": "Bien Élevé", + "name:fr": "Bien Élevé", + "opening_hours": "Mo-Su 12:00-22:00", + "outdoor_seating": "yes", + "phone": "+33 1 45 81 44 35", + "website": "http://www.bieneleve.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3432187, + 48.8739833 + ] + }, + "id": "node/4790815426" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4855673114", + "amenity": "restaurant", + "cuisine": "italian", + "name": "Chez Vincent" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3375197, + 48.8778834 + ] + }, + "id": "node/4855673114" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4855673116", + "amenity": "restaurant", + "cuisine": "french", + "name": "Le Bon Georges" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3372106, + 48.8776422 + ] + }, + "id": "node/4855673116" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4855693222", + "amenity": "restaurant", + "cuisine": "japanese", + "name": "MiZushi" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.337289, + 48.8770812 + ] + }, + "id": "node/4855693222" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4859813179", + "addr:city": "Paris", + "addr:housenumber": "24", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "capacity": "10", + "cuisine": "japanese", + "disused:amenity": "fast_food", + "name": "Gyoza Bar", + "takeaway": "yes", + "takeaway:lunchbox": "unknown" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3369509, + 48.8767825 + ] + }, + "id": "node/4859813179" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4864147785", + "amenity": "restaurant", + "name": "Le Dream Café" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3394129, + 48.8770625 + ] + }, + "id": "node/4864147785" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4908987597", + "addr:city": "Paris", + "addr:housenumber": "7", + "addr:postcode": "75009", + "addr:street": "Rue Cadet", + "amenity": "restaurant", + "check_date": "2023-08-06", + "cuisine": "pizza", + "name": "Le Papacionu", + "opening_hours": "Mo-Sa 12:00-14:30,19:00-23:30", + "outdoor_seating": "yes", + "payment:credit_cards": "yes", + "payment:mastercard": "yes", + "payment:visa": "yes", + "smoking": "outside", + "survey:date": "2018-05-05", + "takeaway": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3427513, + 48.8747685 + ] + }, + "id": "node/4908987597" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4935628429", + "amenity": "restaurant", + "name": "La Condesa" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3427482, + 48.8785156 + ] + }, + "id": "node/4935628429" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4935628430", + "addr:housenumber": "8", + "addr:street": "Rue Hippolyte Lebas", + "amenity": "restaurant", + "description": "Neo-bistrot", + "name": "Caillebotte", + "opening_hours": "Mo-Fr 12:30-14:30,19:30-22:30", + "phone": "+33153208870" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.34083, + 48.8771 + ] + }, + "id": "node/4935628430" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4966817508", + "amenity": "restaurant", + "name": "libshop" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.340406, + 48.8750922 + ] + }, + "id": "node/4966817508" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4966817509", + "amenity": "restaurant", + "contact:mobile": "+33 6 52 22 44 64", + "cuisine": "regional", + "name": "Le P'tit Piano", + "opening_hours": "Mo 07:00-16:00, Tu-Fr 07:00-24:00, Sa-Su 10:00-24:00", + "website": "https://leptitpianobar.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3436884, + 48.8756444 + ] + }, + "id": "node/4966817509" + }, + { + "type": "Feature", + "properties": { + "@id": "node/4973211216", + "addr:city": "Paris", + "addr:postcode": "75009", + "addr:street": "Rue de Châteaudun", + "amenity": "restaurant", + "cuisine": "greek", + "diet:vegan": "yes", + "diet:vegetarian": "yes", + "indoor_seating": "yes", + "name": "Gallika", + "opening_hours": "Mo-Fr 11:30-14:30", + "outdoor_seating": "yes", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.336314, + 48.8760016 + ] + }, + "id": "node/4973211216" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5029994921", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "23", + "contact:phone": "+33 1 55 07 86 52", + "contact:postcode": "75009", + "contact:street": "Rue de la Victoire", + "name": "Louis" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3387445, + 48.8750532 + ] + }, + "id": "node/5029994921" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5132372221", + "addr:housenumber": "31 bis", + "addr:postcode": "75009", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "check_date": "2023-08-06", + "cuisine": "argentinian", + "email": "hola@locolerestaurant.com", + "name": "Loco", + "phone": "+33 970986841", + "website": "http://www.locolerestaurant.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3424946, + 48.8737307 + ] + }, + "id": "node/5132372221" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5186169364", + "amenity": "restaurant", + "cuisine": "pizza", + "name": "Arcimboldo", + "opening_hours": "Tu-Fr 12:00-14:30, Tu-Sa 19:00-22:00", + "phone": "+33 1 48 78 35 54", + "website": "https://arcimboldopizza.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3434194, + 48.8753151 + ] + }, + "id": "node/5186169364" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5196307522", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "11", + "contact:phone": "+33 1 48 24 84 40", + "contact:postcode": "75009", + "contact:street": "Rue Cadet", + "contact:website": "http://royalcadet.fr", + "cuisine": "french", + "name": "Le Royal Cadet", + "name:en": "Brasserie Cadet", + "survey:date": "2018-05-05" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3431504, + 48.8752635 + ] + }, + "id": "node/5196307522" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5209260098", + "addr:postcode": "75009", + "amenity": "restaurant", + "check_date": "2023-08-06", + "cuisine": "japanese", + "name": "Neko Ramen", + "opening_hours": "Mo-Su 11:30-23:00", + "website": "https://www.nekoramen.fr/", + "wheelchair": "limited" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3423234, + 48.8731313 + ] + }, + "id": "node/5209260098" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5302471392", + "addr:city": "Paris", + "addr:housenumber": "15", + "addr:postcode": "75009", + "addr:street": "Rue Notre-Dame-de-Lorette", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Mian", + "website": "https://mianfan.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3380986, + 48.8776793 + ] + }, + "id": "node/5302471392" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5417941785", + "addr:city": "Paris", + "addr:housenumber": "8", + "addr:postcode": "75009", + "addr:street": "Rue de Châteaudun", + "amenity": "restaurant", + "cuisine": "world;healthy", + "email": "contact@nousrestaurant.fr", + "name": "Nous Châteaudun", + "opening_hours": "Mo-Fr 12:00-14:30,19:00-22:30; Sa,Su 12:00-15:00,19:00-22:30", + "smoking": "no", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes", + "website": "http://www.nousrestaurant.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3414237, + 48.8759011 + ] + }, + "id": "node/5417941785" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5667593336", + "amenity": "restaurant", + "cuisine": "lebanese", + "name": "Sannine", + "outdoor_seating": "yes", + "source": "survey 2018" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3426956, + 48.8738696 + ] + }, + "id": "node/5667593336" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5667593337", + "amenity": "restaurant", + "name": "Chez Léon", + "source": "survey 2018" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3428335, + 48.8737555 + ] + }, + "id": "node/5667593337" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5689953454", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "27", + "contact:phone": "+33 9 70 38 61 00", + "contact:postcode": "75009", + "contact:street": "Rue Richer", + "contact:website": "https://www.lessardignac.fr/", + "cuisine": "regional;wine", + "diet:vegetarian": "yes", + "name": "Les Sardignac", + "opening_hours": "Mo-Fr 12:00-15:00,18:00-02:00; Sa 18:00-02:00; Su off", + "outdoor_seating": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3449879, + 48.8739562 + ] + }, + "id": "node/5689953454" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5774910156", + "amenity": "restaurant", + "brand": "Pizza Hut", + "brand:wikidata": "Q191615", + "cuisine": "pizza", + "drive_through": "no", + "name": "Pizza Hut", + "opening_hours": "Mo-Fr 11:30-14:30,18:00-23:00; Sa,Su,PH 11:30-23:00", + "operator": "Pizza Hut" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3444669, + 48.8771365 + ] + }, + "id": "node/5774910156" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5877960608", + "addr:city": "Paris", + "addr:housenumber": "7", + "addr:postcode": "75009", + "addr:street": "Rue Bourdaloue", + "amenity": "restaurant", + "capacity": "20", + "cuisine": "latin_american", + "name": "Isana", + "smoking": "no", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3385935, + 48.8765862 + ] + }, + "id": "node/5877960608" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5913758023", + "amenity": "restaurant", + "contact:email": "nathan@lacaleducotentin.fr", + "contact:phone": "+33 6 23917982", + "contact:website": "https://www.lacaleducotentin.fr/", + "cuisine": "oyster", + "name": "La Cale du Cotentin" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3401432, + 48.8771027 + ] + }, + "id": "node/5913758023" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5916181911", + "addr:city": "Paris", + "addr:housenumber": "29", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Georges", + "amenity": "restaurant", + "capacity": "10", + "name": "Pick and Co", + "smoking": "no", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3370123, + 48.8764072 + ] + }, + "id": "node/5916181911" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5980951946", + "addr:city": "Paris 9eme Arrondissement", + "addr:housenumber": "18", + "addr:postcode": "75009", + "addr:street": "Rue Rodier", + "amenity": "restaurant", + "cuisine": "japanese", + "dog": "no", + "name": "Hotaru", + "opening_hours": "Tu-Sa 12:30-14:30,19:00-22:00", + "payment:mastercard": "yes", + "payment:visa": "yes", + "phone": "+33 1 48 78 33 74", + "smoking": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3427222, + 48.8782074 + ] + }, + "id": "node/5980951946" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5981803288", + "amenity": "restaurant", + "cuisine": "french", + "name": "Kozo" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3373363, + 48.8772425 + ] + }, + "id": "node/5981803288" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5981805085", + "amenity": "restaurant", + "contact:city": "Paris 9eme Arrondissement", + "contact:housenumber": "48", + "contact:phone": "+33 1 42 81 35 94", + "contact:postcode": "75009", + "contact:street": "Rue Saint-Georges", + "name": "Chez Delphine", + "opening_hours": "We-Fr 12:00-14:30, 19:00-22:30; Sa 19:00-22:30", + "ref:FR:SIRET": "85007565600015", + "website": "https://www.restaurantchezdelphine.fr/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3373158, + 48.8771873 + ] + }, + "id": "node/5981805085" + }, + { + "type": "Feature", + "properties": { + "@id": "node/5981805185", + "amenity": "restaurant", + "cuisine": "tibetan", + "diet:vegetarian": "yes", + "name": "Nirvana Dream" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3372412, + 48.8769371 + ] + }, + "id": "node/5981805185" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6003131457", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "2", + "contact:postcode": "75009", + "contact:street": "Rue de Provence", + "cuisine": "italian;pizza", + "name": "Il Piccolo Drouot", + "takeaway": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3417958, + 48.8741188 + ] + }, + "id": "node/6003131457" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6007703105", + "addr:city": "Paris", + "addr:housenumber": "31", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Mizupoke", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3361794, + 48.8766901 + ] + }, + "id": "node/6007703105" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6044004255", + "addr:city": "Paris", + "addr:housenumber": "37", + "addr:postcode": "75009", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "email": "hello@beauetfort.com", + "level": "0", + "name": "Beau & Fort", + "opening_hours": "Mo-Sa 10:00-23:00,Su 10:30-16:00", + "outdoor_seating": "yes", + "phone": "+33 1 45 89 23 96", + "smoking": "outside", + "website": "https://www.beauetfort.com/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3420981, + 48.8741043 + ] + }, + "id": "node/6044004255" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6044106913", + "amenity": "restaurant", + "level": "0", + "name": "La poketerie" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3417873, + 48.8739937 + ] + }, + "id": "node/6044106913" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6044132983", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:email": "contact@thams.fr", + "contact:housenumber": "11", + "contact:phone": "+33 9 84 55 67 40", + "contact:postcode": "75009", + "contact:street": "Rue de Provence", + "contact:website": "http://www.thams.fr/", + "cuisine": "vietnamese;lao;taiwanese", + "level": "0", + "name": "Tham's", + "outdoor_seating": "no", + "smoking": "no", + "takeaway": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3410975, + 48.874012 + ] + }, + "id": "node/6044132983" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6044324520", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "54", + "contact:phone": "+33 1 45 23 18 29", + "contact:postcode": "75009", + "contact:street": "Rue Richer", + "cuisine": "sushi;japanese", + "level": "0", + "name": "Line Sushi", + "outdoor_seating": "no", + "smoking": "no", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3431805, + 48.8740953 + ] + }, + "id": "node/6044324520" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6068528486", + "amenity": "restaurant", + "cuisine": "chinese", + "name": "Au Bonheur", + "old_name": "Orient Express" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3430313, + 48.875112 + ] + }, + "id": "node/6068528486" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6112669236", + "addr:city": "Paris", + "addr:housenumber": "57", + "addr:postcode": "75009", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "diet:vegetarian": "yes", + "name": "Sainbol", + "phone": "+33 9 52 59 37 20", + "smoking": "no", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes", + "website": "http://sainbol.com/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3405363, + 48.8754953 + ] + }, + "id": "node/6112669236" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6140347085", + "amenity": "restaurant", + "name": "Saveur du Si Chuan", + "opening_hours": "Tu-Su 12:00-15:00,18:30-22:30" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3409007, + 48.8753895 + ] + }, + "id": "node/6140347085" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6251324473", + "amenity": "restaurant", + "cuisine": "asian;vietnamese", + "name": "Zen Bo Bun", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3362409, + 48.8766857 + ] + }, + "id": "node/6251324473" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6288844557", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "12", + "contact:postcode": "75009", + "contact:street": "Rue Marguerite de Rochechouart", + "cuisine": "french", + "name": "Les Anges Gourmands", + "phone": "+33 1 48 78 28 83", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3446931, + 48.8771701 + ] + }, + "id": "node/6288844557" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6325739385", + "addr:housenumber": "7", + "addr:postcode": "75009", + "addr:street": "Rue de Châteaudun", + "amenity": "restaurant", + "name": "Little Baobai" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3414884, + 48.8756793 + ] + }, + "id": "node/6325739385" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6388821014", + "amenity": "restaurant", + "name": "Simone Lemon", + "opening_hours": "Mo-Fr 11:45-14:45, 09:00-18:00; Sa, Su 11:30-16:00", + "website": "https://www.simonelemon.com/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3393076, + 48.8742544 + ] + }, + "id": "node/6388821014" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6420966885", + "addr:city": "Paris", + "addr:housenumber": "19", + "addr:postcode": "75009", + "addr:street": "Rue Notre-Dame-de-Lorette", + "amenity": "restaurant", + "cuisine": "venezuelan", + "email": "contact@ajidulce.fr", + "internet_access": "wlan", + "name": "aji dulce", + "name:en": "aji dulce - the taste of Venezuela", + "name:fr": "aji dulce - le goût du Venezuela", + "name:nl": "aji dulce - de smaak van Venezuela", + "phone": "+33 1 83 87 15 56", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes", + "website": "http://www.ajidulce.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3379234, + 48.8778931 + ] + }, + "id": "node/6420966885" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6554151627", + "addr:housenumber": "18", + "addr:street": "Rue Chauchat", + "amenity": "restaurant", + "name": "Palinuro", + "opening_hours": "Mo-Sa 09:00-15:00, 18:00-23:00", + "phone": "+33 1 47 70 94 75" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3397907, + 48.8738792 + ] + }, + "id": "node/6554151627" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6562769648", + "amenity": "restaurant", + "capacity": "8", + "contact:city": "Paris", + "contact:housenumber": "34", + "contact:phone": "+33 1 40 34 21 80", + "contact:postcode": "75009", + "contact:street": "Rue de la Victoire", + "contact:website": "https://www.uglywok.com/", + "cuisine": "thai", + "name": "The Ugly Wok", + "opening_hours": "Mo-Fr 12:00-15:00", + "smoking": "no", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3375723, + 48.8751921 + ] + }, + "id": "node/6562769648" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6602352485", + "addr:housenumber": "20", + "addr:street": "Rue des Martyrs", + "amenity": "restaurant", + "cuisine": "chinese", + "name": "Yoom Dim Sum" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3396821, + 48.877974 + ] + }, + "id": "node/6602352485" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6776083685", + "addr:housenumber": "8", + "addr:street": "Rue Cadet", + "amenity": "restaurant", + "name": "Mam Thai", + "opening_hours": "Mo-Fr 11:30-16:00, 18:00-22:30; Sa-Su 00:00-24:00" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.342717, + 48.8744817 + ] + }, + "id": "node/6776083685" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6791192885", + "amenity": "restaurant", + "name": "Muqam" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3455731, + 48.8752333 + ] + }, + "id": "node/6791192885" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6802180924", + "addr:city": "Paris 9eme Arrondissement", + "addr:housenumber": "17", + "addr:postcode": "75009", + "addr:street": "Rue de Maubeuge", + "amenity": "restaurant", + "cuisine": "chinese;vietnamese", + "name": "Phô Neuf", + "opening_hours": "Tu-Su 12:00-14:30,18:30-22:30", + "phone": "+33148781114" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3435552, + 48.877757 + ] + }, + "id": "node/6802180924" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6905693428", + "addr:city": "Paris", + "addr:housenumber": "43", + "addr:postcode": "75009", + "addr:street": "Rue Laffitte", + "amenity": "restaurant", + "contact:instagram": "majouja.paris", + "cuisine": "arab", + "indoor_seating": "yes", + "name": "Les Piplettes", + "name:en": "Majouja", + "name:es": "Majouja", + "name:fr": "Majouja", + "opening_hours": "Tu-Th 12:00-15:00; Fr-Sa 12:00-15:00, 19:30-22:00", + "outdoor_seating": "yes", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes", + "website": "https://www.majoujaparis.fr/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3384016, + 48.8753137 + ] + }, + "id": "node/6905693428" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6909732685", + "amenity": "restaurant", + "name": "Paris Yum" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3456359, + 48.8758733 + ] + }, + "id": "node/6909732685" + }, + { + "type": "Feature", + "properties": { + "@id": "node/6987051316", + "amenity": "restaurant", + "name": "Café Drouot" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3406278, + 48.8729446 + ] + }, + "id": "node/6987051316" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7004752103", + "amenity": "restaurant", + "changing_table": "no", + "check_date:opening_hours": "2023-04-29", + "cuisine": "ramen;noodle;japanese", + "delivery": "yes", + "internet_access": "wlan", + "internet_access:fee": "no", + "level": "0", + "name": "Naruto Ramen", + "opening_hours": "Su-Th 12:00-22:30, Fr-Sa 12:00-23:00", + "phone": "+33 1 71 60 45 82", + "smoking": "no", + "takeaway": "yes", + "toilets": "yes", + "toilets:access": "customers", + "toilets:wheelchair": "no", + "website": "https://naruto-ramen.fr/fr", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.340611, + 48.8754081 + ] + }, + "id": "node/7004752103" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7011199779", + "amenity": "restaurant", + "name": "Corner Haussmann" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3351626, + 48.8728637 + ] + }, + "id": "node/7011199779" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7124999574", + "addr:housenumber": "15", + "addr:street": "Rue Lamartine", + "amenity": "restaurant", + "cuisine": "chinese", + "name": "Miss SUN", + "name:fr": "Miss SUN", + "name:zh": "遇见长安", + "opening_hours": "Mo-Th 18:30-23:00; Fr-Su 12:30-15:30,18:30-23:00", + "payment:cash": "yes", + "payment:credit_cards": "no", + "payment:debit_cards": "no", + "payment:mastercard": "yes", + "payment:visa": "yes", + "phone": "+33 6 18 85 91 87;+33 1 42 45 37 87", + "reservation": "yes", + "smoking": "no", + "toilets": "yes", + "toilets:wheelchair": "no", + "wheelchair": "limited" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3420687, + 48.8764516 + ] + }, + "id": "node/7124999574" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7211522063", + "addr:city": "Paris", + "addr:housenumber": "37", + "addr:postcode": "75009", + "addr:street": "Rue La Fayette", + "amenity": "restaurant", + "contact:city": "Paris", + "contact:housenumber": "37", + "contact:postcode": "75009", + "contact:street": "Rue La Fayette", + "contact:website": "http://www.come-paris.fr", + "cuisine": "bowl", + "indoor_seating": "yes", + "name": "Côme - La Fayette", + "opening_hours": "Mo-Fr 10:00-15:15", + "outdoor_seating": "yes", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes", + "toilets": "yes", + "website": "http://www.come-paris.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3386851, + 48.8746195 + ] + }, + "id": "node/7211522063" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7227689604", + "addr:city": "Paris", + "addr:housenumber": "55", + "addr:postcode": "75009", + "addr:street": "Rue du Faubourg Montmartre", + "all_you_can_eat": "yes", + "amenity": "restaurant", + "cuisine": "japanese", + "name": "OKITO Le Peletier", + "opening_hours": "Mo-Sa 12:00-15:00,18:30-23:00; Su 18:30-23:00", + "phone": "+33140160923", + "website": "https://lepeletier.okito.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3406528, + 48.8753639 + ] + }, + "id": "node/7227689604" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7289213938", + "addr:city": "Paris", + "addr:housenumber": "18", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "cuisine": "regional", + "name": "Chamaille", + "opening_hours": "Mo-Fr 12:00-14:00; Sa-Su 12:00-16:30", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes", + "website": "https://www.chamailleparis.fr/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.337638, + 48.8768523 + ] + }, + "id": "node/7289213938" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7332920586", + "addr:housenumber": "4", + "addr:postcode": "75009", + "addr:street": "Rue Milton", + "amenity": "restaurant", + "name": "Le Soma (Milton)" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3407269, + 48.8769186 + ] + }, + "id": "node/7332920586" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7926078392", + "addr:housenumber": "46", + "addr:street": "Rue Lamartine", + "amenity": "restaurant", + "cuisine": "african", + "name": "Les marmites de Fa", + "opening_hours": "Mo-Sa 11:00-15:00; Mo-We,Su 19:00-24:00; Th-Sa 19:00-02:00", + "phone": "+33 1 48 74 28 54", + "website": "http://www.lesmarmitesdefa.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3407289, + 48.8766369 + ] + }, + "id": "node/7926078392" + }, + { + "type": "Feature", + "properties": { + "@id": "node/7995599887", + "amenity": "restaurant", + "changing_table": "no", + "cuisine": "chinese;yunnan", + "delivery": "yes", + "level": "0", + "microbrewery": "no", + "name": "Carnet de route", + "opening_hours": "Th-Tu 12:00-15:00,18:30-22:30", + "phone": "+33 1 77 19 55 73", + "self_service": "no", + "smoking": "no", + "takeaway": "yes", + "toilets": "yes", + "toilets:access": "customers", + "toilets:disposal": "flush", + "toilets:position": "seated", + "toilets:wheelchair": "no", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3403982, + 48.8756506 + ] + }, + "id": "node/7995599887" + }, + { + "type": "Feature", + "properties": { + "@id": "node/8005907666", + "addr:city": "Paris", + "addr:housenumber": "34", + "addr:postcode": "75009", + "addr:street": "Rue Notre-Dame-de-Lorette", + "amenity": "restaurant", + "contact:facebook": "https://www.facebook.com/thecurerestaurantparis/", + "contact:instagram": "https://www.instagram.com/thecurerestaurantparis/", + "cuisine": "bowl", + "diet:vegan": "yes", + "diet:vegetarian": "yes", + "email": "contact@thecure-restaurant.com", + "name": "The cure", + "opening_hours": "Mo-Fr 12:00-15:00", + "phone": "+33 9 82 53 53 73", + "takeaway": "yes", + "website": "https://thecure-restaurant.com/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3373252, + 48.878709 + ] + }, + "id": "node/8005907666" + }, + { + "type": "Feature", + "properties": { + "@id": "node/8714652617", + "addr:housenumber": "9", + "addr:postcode": "75009", + "addr:street": "Rue Rodier", + "amenity": "restaurant", + "contact:facebook": "troisfoisplusdepiment.fr", + "contact:instagram": "plus.de.piment", + "cuisine": "asian;chinese", + "email": "admin@3foisplusdepiment.com", + "name": "Trois Fois Plus de Piment", + "opening_hours": "Tu-Su 12:00-15:00, 18:30-22:30", + "phone": "+33 9 75 18 04 50", + "website": "https://troisfoisplusdepiment.fr/fr/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3423903, + 48.8778682 + ] + }, + "id": "node/8714652617" + }, + { + "type": "Feature", + "properties": { + "@id": "node/8853618432", + "amenity": "restaurant", + "cuisine": "uzbek", + "name": "Bukhara", + "phone": "+33 148241742", + "website": "http://ouzbek-resto.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3453171, + 48.8750543 + ] + }, + "id": "node/8853618432" + }, + { + "type": "Feature", + "properties": { + "@id": "node/8863039589", + "amenity": "restaurant", + "cuisine": "salad", + "indoor_seating": "yes", + "name": "Composé", + "outdoor_seating": "yes", + "takeaway": "yes", + "takeaway:customer_container": "yes", + "takeaway:lunchbox": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3366502, + 48.8759575 + ] + }, + "id": "node/8863039589" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9020241824", + "amenity": "restaurant", + "name": "Poké bar" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.345039, + 48.8781221 + ] + }, + "id": "node/9020241824" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9132781020", + "amenity": "restaurant", + "cuisine": "sushi", + "name": "Sushi Boubou" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3458015, + 48.8784761 + ] + }, + "id": "node/9132781020" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9161008838", + "amenity": "restaurant", + "name": "Kinn Khao" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3450698, + 48.8773404 + ] + }, + "id": "node/9161008838" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9455171462", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Saveur Zen" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3408777, + 48.8759383 + ] + }, + "id": "node/9455171462" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9455171476", + "amenity": "restaurant", + "name": "Bercail" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3409963, + 48.8763092 + ] + }, + "id": "node/9455171476" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9455171483", + "amenity": "restaurant", + "cuisine": "indian", + "name": "Swagat" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3423886, + 48.8753512 + ] + }, + "id": "node/9455171483" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9455171486", + "amenity": "restaurant", + "name": "Nautilus" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3418263, + 48.8756523 + ] + }, + "id": "node/9455171486" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9528274317", + "addr:housenumber": "12", + "addr:postcode": "75009", + "addr:street": "Rue de Trévise", + "amenity": "restaurant", + "name": "La bouche bleue", + "website": "https://labouchebleue.com/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3452881, + 48.8733685 + ] + }, + "id": "node/9528274317" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9546668217", + "addr:housenumber": "8", + "addr:postcode": "75009", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "name": "Bleu Bao" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3382922, + 48.8769483 + ] + }, + "id": "node/9546668217" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9558699352", + "amenity": "restaurant", + "cuisine": "burger", + "name": "First LAP" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3404889, + 48.878225 + ] + }, + "id": "node/9558699352" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9558735500", + "amenity": "restaurant", + "butcher": "poultry", + "name": "Chez Plume", + "shop": "butcher" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3394079, + 48.8770283 + ] + }, + "id": "node/9558735500" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9558747717", + "amenity": "restaurant", + "cuisine": "asian", + "name": "Shizuka" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3400432, + 48.8783109 + ] + }, + "id": "node/9558747717" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9558784417", + "amenity": "restaurant", + "name": "Poppy" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3405847, + 48.8767842 + ] + }, + "id": "node/9558784417" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9558798517", + "amenity": "restaurant", + "name": "Creime" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3416793, + 48.876621 + ] + }, + "id": "node/9558798517" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9569512617", + "amenity": "restaurant", + "cuisine": "thai", + "name": "Prik Thaï" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3456865, + 48.8787423 + ] + }, + "id": "node/9569512617" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9609564349", + "amenity": "restaurant", + "name": "La démesure" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3415617, + 48.874518 + ] + }, + "id": "node/9609564349" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9610322971", + "amenity": "restaurant", + "cuisine": "asian" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3414998, + 48.8745113 + ] + }, + "id": "node/9610322971" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9610338336", + "amenity": "restaurant", + "indoor_seating": "yes", + "name": "Table neuf", + "opening_hours": "Mo-Sa 12:00-15:00,19:00-23:00", + "outdoor_seating": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3442234, + 48.8735546 + ] + }, + "id": "node/9610338336" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9610343518", + "addr:housenumber": "47", + "addr:postcode": "75009", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "contact:facebook": "CaramelSarrasin", + "contact:instagram": "caramel_sarrasin", + "cuisine": "crepe", + "internet_access": "wlan", + "name": "Caramel Sarrasin", + "opening_hours": "Mo-We 10:30-14:00; Th-Fr 10:30-14:30, 19:00-22:00", + "phone": "09 51 25 80 04", + "website": "https://caramelsarrasin.com/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3413623, + 48.874617 + ] + }, + "id": "node/9610343518" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9629671717", + "addr:housenumber": "15", + "addr:postcode": "75009", + "addr:street": "Rue Hippolyte Lebas", + "amenity": "restaurant", + "name": "Pompette" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3400432, + 48.8771167 + ] + }, + "id": "node/9629671717" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9741286318", + "addr:housenumber": "56", + "addr:postcode": "75009", + "addr:street": "Rue Richer", + "amenity": "restaurant", + "cuisine": "thai", + "name": "Ma Cantine Thaï", + "opening_hours": "Mo-Fr 12:00-15:00", + "phone": "+33 144830230" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3428812, + 48.8741017 + ] + }, + "id": "node/9741286318" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9741488318", + "addr:housenumber": "56", + "addr:postcode": "75009", + "addr:street": "Rue Richer", + "amenity": "restaurant", + "cuisine": "thai", + "name": "Ma Cantine Thaï", + "opening_hours": "Mo-Fr 12:00-15:00", + "phone": "+33 144830230" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3428664, + 48.8740904 + ] + }, + "id": "node/9741488318" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9781734723", + "addr:city": "Paris", + "addr:country": "FR", + "addr:housenumber": "17", + "addr:postcode": "75009", + "addr:street": "Rue Bleue", + "amenity": "restaurant", + "cuisine": "armenian", + "name": "Cantine de la Maison de la Culture Arménienne de Paris", + "opening_hours": "Mo-Sa 12:00-15:00,19:00-23:00", + "operator": "Maison de la Culture Arménienne", + "phone": "+33 1 48 24 63 89", + "website": "https://www.facebook.com/Cantine-de-la-Maison-de-la-Culture-Armenienne-de-Paris-298995103835749" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3461182, + 48.8753538 + ] + }, + "id": "node/9781734723" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9801114117", + "amenity": "restaurant", + "name": "La Cave Drouot" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3406438, + 48.8727548 + ] + }, + "id": "node/9801114117" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9801172317", + "amenity": "restaurant", + "name": "Lupo caffè" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3425908, + 48.8730747 + ] + }, + "id": "node/9801172317" + }, + { + "type": "Feature", + "properties": { + "@id": "node/9864287812", + "amenity": "restaurant", + "cuisine": "thai", + "name": "Au Petit Te" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3380088, + 48.8778002 + ] + }, + "id": "node/9864287812" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10027954178", + "amenity": "restaurant", + "name": "Miznon" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3426102, + 48.872949 + ] + }, + "id": "node/10027954178" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10264645009", + "addr:housenumber": "17", + "addr:street": "Rue Bleue", + "amenity": "restaurant", + "name": "Restaurant Arménien - ambassade Arménie" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3461153, + 48.8757625 + ] + }, + "id": "node/10264645009" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10286310509", + "addr:housenumber": "17", + "addr:street": "Rue Le Peletier", + "amenity": "restaurant", + "check_date": "2023-08-06", + "cuisine": "chinese;fish", + "internet_access": "wlan", + "name": "Restaurant Sichuan", + "name:en": "Sichuan restaurant", + "name:zh": "川里川外", + "opening_hours": "Mo-Tu 12:00-14:30,18:30-22:30; Th-Su 12:00-14:30,18:30-22:30", + "payment:mastercard": "yes", + "payment:visa": "yes", + "phone": "+33 1 47 70 64 11" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3384403, + 48.872776 + ] + }, + "id": "node/10286310509" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10308274409", + "addr:street": "Rue Saint-Lazare", + "amenity": "restaurant", + "cuisine": "pizza", + "name": "Pépé Ronchon" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3360435, + 48.8767002 + ] + }, + "id": "node/10308274409" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10310449609", + "addr:housenumber": "31", + "addr:street": "Rue de Châteaudun", + "amenity": "restaurant", + "indoor_seating": "yes", + "name": "Pokawa", + "opening_hours": "Mo-Su 11:00-14:30, 18:30-22:30", + "outdoor_seating": "no", + "phone": "+33185736972", + "website": "https://restaurants.pokawa.com/poke-bowl-paris-chateaudun/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3369341, + 48.8759752 + ] + }, + "id": "node/10310449609" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10316518609", + "addr:housenumber": "20", + "addr:street": "Rue Milton", + "amenity": "restaurant", + "name": "Batoù" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.341216, + 48.8782605 + ] + }, + "id": "node/10316518609" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10542849003", + "addr:city": "Paris", + "addr:housenumber": "43", + "addr:postcode": "75009", + "addr:street": "Rue La Fayette", + "amenity": "restaurant", + "cuisine": "mediterranean", + "diet:non-vegetarian": "yes", + "diet:vegetarian": "yes", + "indoor_seating": "yes", + "name": "Med'eat", + "opening_hours": "Mo-Fr 11:45-14:15", + "outdoor_seating": "yes", + "phone": "+33 1 40 03 85 73", + "website": "https://www.medeat.fr/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3395568, + 48.8748583 + ] + }, + "id": "node/10542849003" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10571177714", + "amenity": "restaurant", + "name": "Alleudium" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3429008, + 48.8784676 + ] + }, + "id": "node/10571177714" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10604080605", + "addr:housenumber": "43", + "addr:street": "Rue Laffitte", + "amenity": "restaurant", + "name": "Fimmina Pizzeria", + "opening_hours": "Su-Fr 12:00-22:30" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3383617, + 48.8753047 + ] + }, + "id": "node/10604080605" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10613824711", + "amenity": "restaurant", + "cuisine": "french", + "name": "Berrie" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3355468, + 48.8766339 + ] + }, + "id": "node/10613824711" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10613829210", + "amenity": "restaurant", + "cuisine": "asian", + "diet:vegetarian": "no", + "name": "Bistro Dam Korean BBQ" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3364498, + 48.8766783 + ] + }, + "id": "node/10613829210" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10660907862", + "addr:housenumber": "17", + "addr:street": "Rue de Châteaudun", + "amenity": "restaurant", + "check_date": "2023-02-14", + "cuisine": "noodle;ramen;japanese", + "name": "Yatai Ramen", + "payment:contactless": "yes", + "payment:credit_cards": "yes" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3391516, + 48.8758331 + ] + }, + "id": "node/10660907862" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10682577392", + "amenity": "restaurant", + "name": "Mamou" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3354032, + 48.8738544 + ] + }, + "id": "node/10682577392" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10801608378", + "amenity": "restaurant", + "name": "Lorette" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3380722, + 48.8767795 + ] + }, + "id": "node/10801608378" + }, + { + "type": "Feature", + "properties": { + "@id": "node/10876238296", + "addr:city": "Paris", + "addr:housenumber": "23", + "addr:postcode": "75009", + "addr:street": "Passage Verdeau", + "amenity": "restaurant", + "cuisine": "korean", + "diet:halal": "yes", + "diet:kosher": "yes", + "diet:vegetarian": "yes", + "fixme": "Emplacement exact", + "name": "Keopi", + "opening_hours": "Tu-Su 10:00-20:00", + "phone": "+33 1 47 70 86 24", + "website": "https://keopi-paris.fr/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.342322, + 48.8736803 + ] + }, + "id": "node/10876238296" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11034414509", + "amenity": "restaurant", + "cuisine": "ramen", + "name": "Menkicchi Ramen" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3426992, + 48.8729311 + ] + }, + "id": "node/11034414509" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11035523081", + "addr:city": "Paris", + "addr:housenumber": "66", + "addr:postcode": "75009", + "addr:street": "Rue du Faubourg Montmartre", + "amenity": "restaurant", + "cuisine": "mexican", + "delivery": "yes", + "name": "El Chingon", + "opening_hours": "Mo-Su 11:30-02:00", + "phone": "+33981636187", + "website": "http://www.elchingon.fr/" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3399715, + 48.8763269 + ] + }, + "id": "node/11035523081" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11063430375", + "addr:housenumber": "48", + "addr:street": "Rue laffitte", + "amenity": "restaurant", + "name": "Juste", + "opening_hours": "Mo 18:30-22:30; Tu-Fr 12:00-14:30, 18:30-22:30; Sa 12:00-15:30, 18:30-23:00", + "phone": "0982339347", + "website": "https://juste-producteur.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3385009, + 48.8750353 + ] + }, + "id": "node/11063430375" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11090136337", + "amenity": "restaurant", + "changing_table": "no", + "cuisine": "Chinese; sichuan", + "delivery": "yes", + "level": "0", + "name": "Liziba Chongqing Chicken Pot", + "outdoor_seating": "no", + "smoking": "no", + "takeaway": "yes", + "toilets": "yes", + "toilets:access": "customers", + "toilets:disposal": "flush", + "toilets:position": "seated", + "toilets:wheelchair": "no", + "wheelchair": "no" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3455585, + 48.8740938 + ] + }, + "id": "node/11090136337" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11102867827", + "amenity": "restaurant", + "name": "La Cantine Marocaine" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3456393, + 48.8739711 + ] + }, + "id": "node/11102867827" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11114080053", + "amenity": "restaurant", + "cuisine": "latino", + "name": "Mi Ranchito Paisa", + "opening_hours": "Mo-Fr 12:00-14:30,19:00-23:30; Sa,Su 12:00-23:30", + "phone": "+33 1 48 78 45 94", + "ref:FR:SIREN": "445260193", + "ref:FR:SIRET": "44526019300013", + "website": "https://miranchitopaisa.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3448229, + 48.8764947 + ] + }, + "id": "node/11114080053" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11114080055", + "amenity": "restaurant", + "cuisine": "thai", + "name": "Thaï Thaï Gourmand" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3444499, + 48.8770926 + ] + }, + "id": "node/11114080055" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11114080058", + "amenity": "restaurant", + "description": "Restaurant in the hotel La Fantaisie.", + "name": "Golden Poppy", + "outdoor_seating": "yes", + "website": "https://goldenpoppy.com" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3434648, + 48.8753633 + ] + }, + "id": "node/11114080058" + }, + { + "type": "Feature", + "properties": { + "@id": "node/11171620994", + "addr:housenumber": "43", + "addr:street": "Rue Laffite", + "amenity": "restaurant", + "description": "Cantine Kabyle", + "name": "Majoja", + "opening_hours": "Tu-Fr 12:00-15:00; Sa 12:00-16:30", + "phone": "0951492518", + "website": "www.majoujaparis.fr" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.3384573, + 48.8754385 + ] + }, + "id": "node/11171620994" + } + ] +} \ No newline at end of file diff --git a/NearestNeighbors/generate_circuit.py b/NearestNeighbors/generate_circuit.py new file mode 100644 index 0000000..4b52c6e --- /dev/null +++ b/NearestNeighbors/generate_circuit.py @@ -0,0 +1,73 @@ +import numpy as np +from concrete import fhe +import config +import network + +_, point_coordinates = network.get() + +N_PTS = point_coordinates.shape[0] + + +points = fhe.LookupTable(point_coordinates.flatten()) + + +def get_point(index): + return (points[2*index], points[2*index + 1]) + + +def all_distances(x, y): + xs = np.arange(0, 2 * N_PTS, 2) + ys = np.arange(1, 2 * N_PTS, 2) + a = abs(points[xs] - x) + b = abs(points[ys] - y) + return a + b + + + +# TLUs +relu = fhe.univariate(lambda x: x if x > 0 else 0) +is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0) +arg_selection = fhe.univariate(lambda x: (x-1)//2 if x % 2 else 0) # relu packed with a flag (alternating between 0 and relu) + + + +def swap(this_idx, this_dist, that_idx, that_dist): + """ + Swaps this and that if this > that. + We must pass both the index and the distance for both this and that. + + Returns: + idxmin, min, idxmax, max of this and that based on distance + """ + diff = this_dist - that_dist + idx = arg_selection(2 * (this_idx - that_idx) + is_positive(diff)) + dist = relu(diff) + + idx_min = this_idx - idx + idx_max = that_idx + idx + dist_min = this_dist - dist + dist_max = that_dist + dist + return fhe.array([idx_min, dist_min, idx_max, dist_max]) + + +@fhe.compiler({"x": "encrypted", "y": "encrypted"}) +def knn(x, y): + dist = all_distances(x, y) + idx = list(range(N_PTS)) + for k in range(2): + for i in range(k+1, N_PTS): + idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i]) + return fhe.array([get_point(idx[j]) for j in range(2)]) + + +inputset = [ + (1550, 4289), + (1908, 3972), + (1705, 4253), + (1980, 4071) + ] + + +circuit = knn.compile(inputset) + +circuit.server.save(config.circuit_filepath) \ No newline at end of file diff --git a/NearestNeighbors/network.py b/NearestNeighbors/network.py new file mode 100644 index 0000000..19ab6b9 --- /dev/null +++ b/NearestNeighbors/network.py @@ -0,0 +1,17 @@ +import geopandas as gpd +import config +import numpy + + + +def get(): + all_restaurants = gpd.read_file(config.restaurants_filepath) + sub_restaurants = all_restaurants.sample(config.total_restaurants_number, random_state = 42) + point_coordinates = [list([int(point.coords[0][0])%10000, + int(point.coords[0][1])%10000]) + for point in sub_restaurants['geometry'].to_crs("epsg:2154")] + point_coordinates = numpy.array(point_coordinates) + + return sub_restaurants, point_coordinates + + From 6234940633d00f4deb41fbd4162d90bdc18dbc95 Mon Sep 17 00:00:00 2001 From: Riad15l Date: Mon, 2 Oct 2023 16:03:21 +0200 Subject: [PATCH 3/9] FEAT : first steps of app --- NearestNeighbors/app.py | 126 ++++++++++++++++++++++++++++++++++++++ NearestNeighbors/utils.py | 35 +++++++++++ 2 files changed, 161 insertions(+) create mode 100644 NearestNeighbors/app.py create mode 100644 NearestNeighbors/utils.py diff --git a/NearestNeighbors/app.py b/NearestNeighbors/app.py new file mode 100644 index 0000000..30cfded --- /dev/null +++ b/NearestNeighbors/app.py @@ -0,0 +1,126 @@ +from streamlit_folium import st_folium +import streamlit as st +import network +import config +from utils import set_up_server, set_up_client, display_encrypted, transform_point +from shapely.geometry import Point + +import folium +import streamlit as st +from streamlit_folium import st_folium +from shapely.geometry import Point +import geopandas +import config +from concrete import fhe + +st.set_page_config(layout="wide") +server = set_up_server() +client = set_up_client(server.client_specs.serialize()) + + +restaurants, point_coordinates = network.get() + +m = restaurants.explore( + scheme="naturalbreaks", + tooltip="name", + popup=["name"], + name="Quadratic-Paris", + color="red", + marker_kwds=dict(radius=5, fill=True, name='node_id'), +) + + + +c1, c2, c3 = st.columns([1, 3, 1]) + +with c1: + st.write("**Client-side**") + +with c3: + st.write("**Server-side**") + + +if 'evaluation_key' not in st.session_state: + with c1: + if st.button('Generate keys'): + with st.spinner('Generating keys'): + client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) + st.session_state['evaluation_key'] = client.evaluation_keys.serialize() + st.experimental_rerun() +else: + with c1: + st.write("Encryption/decryption keys and evaluation keys are generated.") + st.write("The evaluation key is sent to the server.") + with c3: + st.write(f"Evaluation key: {display_encrypted(st.session_state['evaluation_key'])}") + + if 'position' not in st.session_state : + with c1: + st.write("Select your starting position on the map") + + with c2: + map = st_folium(m, height=350, width=700) + + with c3: + st.write("") + + if map.get("last_clicked"): + position = Point(map.get("last_clicked")["lng"], map.get("last_clicked")["lat"]) + st.session_state['position'] = position + st.write(f"Selected starting point is: {position}") + st.experimental_rerun() + + if 'position' in st.session_state and 'encrypted_position' not in st.session_state : + + position = st.session_state['position'] + folium.Marker([position.y, position.x], popup="Starting point", tooltip="Starting point").add_to(m) + + with c1: + st.write(f"Selected starting point is: ({position.x}, {position.y})") + + if st.button('Encrypt and send inputs'): + with st.spinner('Encrypting inputs'): + client.keys.load(config.keys_filepath) + + x, y = transform_point(position.x, position.y) + encrypted_x, encrypted_y = client.encrypt(x, y) + encrypted_position = (encrypted_x.serialize(), encrypted_y.serialize()) + st.session_state['encrypted_position'] = encrypted_position + st.experimental_rerun() + with c2: + map = st_folium(m, height=350, width=700) + + with c3: + st.write("") + + if 'encrypted_position' in st.session_state and 'encrypted_results' not in st.session_state: + position = st.session_state['position'] + folium.Marker([position.y, position.x], popup="Starting point", tooltip="Starting point").add_to(m) + + with c1: + st.write(f"Selected starting point is: ({position.x}, {position.y})") + with c2: + map = st_folium(m, height=350, width=700) + with c3: + st.write("") + st.write("") + encrypted_x = st.session_state['encrypted_position'][0] + encrypted_y = st.session_state['encrypted_position'][1] + + st.write(f"Received starting point: ({display_encrypted(encrypted_x)},{display_encrypted(encrypted_y)})") + if st.button(f'Find {config.number_of_neighbors} nearest neighbors'): + with st.spinner('Computing'): + deserialized_x = fhe.Value.deserialize(encrypted_x) + deserialized_y = fhe.Value.deserialize(encrypted_y) + deserialized_evaluation_keys = fhe.EvaluationKeys.deserialize(st.session_state['evaluation_key']) + client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) + + # TODO complete this part + st.experimental_rerun() +# map = st_folium(m, height=350, width=700) + + + +# if map.get("last_clicked"): +# st.write(map.get("last_clicked")) + diff --git a/NearestNeighbors/utils.py b/NearestNeighbors/utils.py new file mode 100644 index 0000000..5cefa39 --- /dev/null +++ b/NearestNeighbors/utils.py @@ -0,0 +1,35 @@ +import pandas as pd +from concrete import fhe +from config import circuit_filepath +from shapely.geometry import Point +import geopandas as gpd + +def set_up_server(): + try: + server = fhe.Server.load(circuit_filepath) + except Exception: + raise Exception(f"Something went wrong with the circuit. Make sure that the circuit exists in {circuit_filepath}. If not run python generate_circuit.py.") + + return server + +def set_up_client(serialized_client_specs): + # Setting up client + client_specs = fhe.ClientSpecs.deserialize(serialized_client_specs) + client = fhe.Client(client_specs) + + return client + +def display_encrypted(encrypted_object): + encoded_text = encrypted_object.hex() + res = '...' + encoded_text[-10:] + return res + + +def transform_point(longitude, latitude): + gdf = gpd.GeoDataFrame({'geometry': [Point(longitude, latitude)]}, crs='EPSG:4326') + gdf = gdf.to_crs('EPSG:2154') + x, y = gdf.geometry.iloc[0].x, gdf.geometry.iloc[0].y + x = int(x) % 10000 + y = int(y) % 10000 + + return x, y \ No newline at end of file From c4eaa2fd28117e90d700fca3daeb3df0b3358491 Mon Sep 17 00:00:00 2001 From: Riad15l Date: Mon, 9 Oct 2023 17:40:08 +0200 Subject: [PATCH 4/9] FEAT: streamlit example for knn --- NearestNeighbors/app.py | 76 +++++++++++++++++++++++----- NearestNeighbors/generate_circuit.py | 4 +- NearestNeighbors/network.py | 3 +- NearestNeighbors/utils.py | 15 +++++- 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/NearestNeighbors/app.py b/NearestNeighbors/app.py index 30cfded..c5be9ff 100644 --- a/NearestNeighbors/app.py +++ b/NearestNeighbors/app.py @@ -2,9 +2,9 @@ import streamlit as st import network import config -from utils import set_up_server, set_up_client, display_encrypted, transform_point +from utils import set_up_server, set_up_client, display_encrypted, transform_point, process_result from shapely.geometry import Point - +import time import folium import streamlit as st from streamlit_folium import st_folium @@ -114,13 +114,65 @@ deserialized_y = fhe.Value.deserialize(encrypted_y) deserialized_evaluation_keys = fhe.EvaluationKeys.deserialize(st.session_state['evaluation_key']) client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) - - # TODO complete this part - st.experimental_rerun() -# map = st_folium(m, height=350, width=700) - - - -# if map.get("last_clicked"): -# st.write(map.get("last_clicked")) - + res = server.run(deserialized_x, deserialized_y, evaluation_keys=deserialized_evaluation_keys) + st.session_state['encrypted_results'] = fhe.Value.serialize(res) + st.experimental_rerun() + if 'encrypted_results' in st.session_state and 'decrypted_result' not in st.session_state : + position = st.session_state['position'] + folium.Marker([position.y, position.x], popup="Starting point", tooltip="Starting point").add_to(m) + res = st.session_state['encrypted_results'] + with c1: + st.write(f"Selected starting point is: ({position.x}, {position.y})") + st.write(f"The received encrypted result is: {display_encrypted(res)}.") + if st.button('Decrypt result'): + with st.spinner('Computing'): + client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) + deser_res = fhe.Value.deserialize(res) + decrypted_result = client.decrypt(deser_res) + st.session_state['decrypted_result'] = decrypted_result + st.experimental_rerun() + with c2: + map = st_folium(m, height=350, width=700) + + + with c3: + st.write("") + st.write("") + encrypted_x = st.session_state['encrypted_position'][0] + encrypted_y = st.session_state['encrypted_position'][1] + + st.write(f"Received starting point: ({display_encrypted(encrypted_x)},{display_encrypted(encrypted_y)})") + st.write(f"The encrypted result {display_encrypted(res)} is sent to the client.") + + if 'decrypted_result' in st.session_state: + position = st.session_state['position'] + folium.Marker([position.y, position.x], popup="Starting point", tooltip="Starting point").add_to(m) + res = st.session_state['encrypted_results'] + decrypted_result = st.session_state['decrypted_result'] + processed_result, restaurants_list = process_result(restaurants, decrypted_result) + for index, enc_res in enumerate(processed_result): + folium.Marker([enc_res.y, enc_res.x], popup=restaurants_list[index], tooltip=restaurants_list[index], + icon=folium.Icon(color='black',icon_color='#FFFF00')).add_to(m) + + with c1: + st.write(f"Selected starting point is: ({position.x}, {position.y})") + st.write(f"The received encrypted result is: {display_encrypted(res)}.") + st.write(f"The {config.number_of_neighbors} closest restaurant to your location are:") + for index, rest in enumerate(restaurants_list): + st.write(f"{index+1}. {rest}.") + if st.button('Restart'): + for key in st.session_state.keys(): + if key != 'evaluation_key': + del st.session_state[key] + st.experimental_rerun() + with c2: + map = st_folium(m, height=350, width=700) + + with c3: + st.write("") + st.write("") + encrypted_x = st.session_state['encrypted_position'][0] + encrypted_y = st.session_state['encrypted_position'][1] + + st.write(f"Received starting point: ({display_encrypted(encrypted_x)},{display_encrypted(encrypted_y)})") + st.write(f"The encrypted result {display_encrypted(res)} is sent to the client.") diff --git a/NearestNeighbors/generate_circuit.py b/NearestNeighbors/generate_circuit.py index 4b52c6e..d9f033a 100644 --- a/NearestNeighbors/generate_circuit.py +++ b/NearestNeighbors/generate_circuit.py @@ -54,10 +54,10 @@ def swap(this_idx, this_dist, that_idx, that_dist): def knn(x, y): dist = all_distances(x, y) idx = list(range(N_PTS)) - for k in range(2): + for k in range(config.number_of_neighbors): for i in range(k+1, N_PTS): idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i]) - return fhe.array([get_point(idx[j]) for j in range(2)]) + return fhe.array([get_point(idx[j]) for j in range(config.number_of_neighbors)]) inputset = [ diff --git a/NearestNeighbors/network.py b/NearestNeighbors/network.py index 19ab6b9..ae16960 100644 --- a/NearestNeighbors/network.py +++ b/NearestNeighbors/network.py @@ -5,7 +5,8 @@ def get(): - all_restaurants = gpd.read_file(config.restaurants_filepath) + all_restaurants = gpd.read_file(config.restaurants_filepath) + all_restaurants = all_restaurants.dropna(subset=["name","cuisine"]) sub_restaurants = all_restaurants.sample(config.total_restaurants_number, random_state = 42) point_coordinates = [list([int(point.coords[0][0])%10000, int(point.coords[0][1])%10000]) diff --git a/NearestNeighbors/utils.py b/NearestNeighbors/utils.py index 5cefa39..618a0f1 100644 --- a/NearestNeighbors/utils.py +++ b/NearestNeighbors/utils.py @@ -32,4 +32,17 @@ def transform_point(longitude, latitude): x = int(x) % 10000 y = int(y) % 10000 - return x, y \ No newline at end of file + return x, y + + +def process_result(rest, result): + processed_result = [] + restaurants_list = [] + for res in result: + mask1 = rest['geometry'].to_crs("epsg:2154").apply(lambda geom: int(geom.x) % 10000 == res[0]) + mask2 = rest['geometry'].to_crs("epsg:2154").apply(lambda geom: int(geom.y) % 10000 == res[1]) + final_mask = mask1 & mask2 + result_df = rest[final_mask] + processed_result.append(result_df.geometry) + restaurants_list.append(f"{result_df.name.iloc[0]}, {result_df.cuisine.iloc[0]}") + return processed_result, restaurants_list \ No newline at end of file From fd7d721f1aa7ba0279221e482e20d16cec514b47 Mon Sep 17 00:00:00 2001 From: Riad15l Date: Wed, 8 Nov 2023 12:18:44 +0100 Subject: [PATCH 5/9] REFACTOR: refactor code and add doc --- NearestNeighbors/app.py | 142 ++++++++----------------------- NearestNeighbors/config.py | 2 +- NearestNeighbors/utils.py | 166 ++++++++++++++++++++++++++++++++++--- 3 files changed, 192 insertions(+), 118 deletions(-) diff --git a/NearestNeighbors/app.py b/NearestNeighbors/app.py index c5be9ff..dc5e057 100644 --- a/NearestNeighbors/app.py +++ b/NearestNeighbors/app.py @@ -1,82 +1,57 @@ -from streamlit_folium import st_folium -import streamlit as st import network -import config from utils import set_up_server, set_up_client, display_encrypted, transform_point, process_result from shapely.geometry import Point -import time -import folium import streamlit as st -from streamlit_folium import st_folium from shapely.geometry import Point -import geopandas import config +from utils import set_up_server, set_up_client, display_encrypted, add_marker,\ + display_map, init_session, add_to_server_side, add_to_client_side, display_client_side,\ + display_server_side, restart_session from concrete import fhe -st.set_page_config(layout="wide") server = set_up_server() client = set_up_client(server.client_specs.serialize()) - - restaurants, point_coordinates = network.get() -m = restaurants.explore( - scheme="naturalbreaks", - tooltip="name", - popup=["name"], - name="Quadratic-Paris", - color="red", - marker_kwds=dict(radius=5, fill=True, name='node_id'), -) - - - -c1, c2, c3 = st.columns([1, 3, 1]) +c1, c2, c3 = init_session() with c1: - st.write("**Client-side**") - + display_client_side() +with c2: + st_map = display_map(restaurants) with c3: - st.write("**Server-side**") + display_server_side() + +# keys generation view if 'evaluation_key' not in st.session_state: with c1: if st.button('Generate keys'): with st.spinner('Generating keys'): client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) st.session_state['evaluation_key'] = client.evaluation_keys.serialize() - st.experimental_rerun() + add_to_client_side("Encryption/decryption keys and evaluation keys are generated.") + add_to_client_side("The evaluation key is sent to the server.") + add_to_server_side(f"Evaluation key: {display_encrypted(st.session_state['evaluation_key'])}") + st.rerun() + else: - with c1: - st.write("Encryption/decryption keys and evaluation keys are generated.") - st.write("The evaluation key is sent to the server.") - with c3: - st.write(f"Evaluation key: {display_encrypted(st.session_state['evaluation_key'])}") if 'position' not in st.session_state : with c1: - st.write("Select your starting position on the map") + st.write("Select your starting position on the st_map") - with c2: - map = st_folium(m, height=350, width=700) - - with c3: - st.write("") - - if map.get("last_clicked"): - position = Point(map.get("last_clicked")["lng"], map.get("last_clicked")["lat"]) + if st_map.get("last_clicked"): + position = Point(st_map.get("last_clicked")["lng"], st_map.get("last_clicked")["lat"]) + add_to_client_side(f"Selected starting point is: ({position.x}, {position.y})") st.session_state['position'] = position - st.write(f"Selected starting point is: {position}") - st.experimental_rerun() + st.rerun() if 'position' in st.session_state and 'encrypted_position' not in st.session_state : position = st.session_state['position'] - folium.Marker([position.y, position.x], popup="Starting point", tooltip="Starting point").add_to(m) - with c1: - st.write(f"Selected starting point is: ({position.x}, {position.y})") if st.button('Encrypt and send inputs'): with st.spinner('Encrypting inputs'): @@ -85,29 +60,16 @@ x, y = transform_point(position.x, position.y) encrypted_x, encrypted_y = client.encrypt(x, y) encrypted_position = (encrypted_x.serialize(), encrypted_y.serialize()) + add_to_server_side(f"Received starting point: ({display_encrypted(encrypted_position[0])},{display_encrypted(encrypted_position[1])})") st.session_state['encrypted_position'] = encrypted_position - st.experimental_rerun() - with c2: - map = st_folium(m, height=350, width=700) - - with c3: - st.write("") + st.rerun() if 'encrypted_position' in st.session_state and 'encrypted_results' not in st.session_state: - position = st.session_state['position'] - folium.Marker([position.y, position.x], popup="Starting point", tooltip="Starting point").add_to(m) - with c1: - st.write(f"Selected starting point is: ({position.x}, {position.y})") - with c2: - map = st_folium(m, height=350, width=700) with c3: - st.write("") - st.write("") encrypted_x = st.session_state['encrypted_position'][0] encrypted_y = st.session_state['encrypted_position'][1] - st.write(f"Received starting point: ({display_encrypted(encrypted_x)},{display_encrypted(encrypted_y)})") if st.button(f'Find {config.number_of_neighbors} nearest neighbors'): with st.spinner('Computing'): deserialized_x = fhe.Value.deserialize(encrypted_x) @@ -115,64 +77,32 @@ deserialized_evaluation_keys = fhe.EvaluationKeys.deserialize(st.session_state['evaluation_key']) client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) res = server.run(deserialized_x, deserialized_y, evaluation_keys=deserialized_evaluation_keys) - st.session_state['encrypted_results'] = fhe.Value.serialize(res) - st.experimental_rerun() + ser_res = fhe.Value.serialize(res) + add_to_server_side(f"The encrypted result {display_encrypted(ser_res)} is sent to the client.") + add_to_client_side(f"The received encrypted result is: {display_encrypted(ser_res)}.") + st.session_state['encrypted_results'] = ser_res + st.rerun() + if 'encrypted_results' in st.session_state and 'decrypted_result' not in st.session_state : - position = st.session_state['position'] - folium.Marker([position.y, position.x], popup="Starting point", tooltip="Starting point").add_to(m) res = st.session_state['encrypted_results'] with c1: - st.write(f"Selected starting point is: ({position.x}, {position.y})") - st.write(f"The received encrypted result is: {display_encrypted(res)}.") if st.button('Decrypt result'): with st.spinner('Computing'): client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) deser_res = fhe.Value.deserialize(res) decrypted_result = client.decrypt(deser_res) + process_result(restaurants, decrypted_result) st.session_state['decrypted_result'] = decrypted_result - st.experimental_rerun() - with c2: - map = st_folium(m, height=350, width=700) + st.rerun() - with c3: - st.write("") - st.write("") - encrypted_x = st.session_state['encrypted_position'][0] - encrypted_y = st.session_state['encrypted_position'][1] - - st.write(f"Received starting point: ({display_encrypted(encrypted_x)},{display_encrypted(encrypted_y)})") - st.write(f"The encrypted result {display_encrypted(res)} is sent to the client.") - if 'decrypted_result' in st.session_state: - position = st.session_state['position'] - folium.Marker([position.y, position.x], popup="Starting point", tooltip="Starting point").add_to(m) - res = st.session_state['encrypted_results'] - decrypted_result = st.session_state['decrypted_result'] - processed_result, restaurants_list = process_result(restaurants, decrypted_result) - for index, enc_res in enumerate(processed_result): - folium.Marker([enc_res.y, enc_res.x], popup=restaurants_list[index], tooltip=restaurants_list[index], - icon=folium.Icon(color='black',icon_color='#FFFF00')).add_to(m) - + # for index, enc_res in enumerate(processed_result): + # # folium.Marker([enc_res.y, enc_res.x], popup=restaurants_list[index], tooltip=restaurants_list[index], + # # icon=folium.Icon(color='black',icon_color='#FFFF00')).add_to(m) + # add_marker(Point(enc_res.y, enc_res.x), restaurants_list[index]) + # st.rerun() with c1: - st.write(f"Selected starting point is: ({position.x}, {position.y})") - st.write(f"The received encrypted result is: {display_encrypted(res)}.") - st.write(f"The {config.number_of_neighbors} closest restaurant to your location are:") - for index, rest in enumerate(restaurants_list): - st.write(f"{index+1}. {rest}.") - if st.button('Restart'): - for key in st.session_state.keys(): - if key != 'evaluation_key': - del st.session_state[key] - st.experimental_rerun() - with c2: - map = st_folium(m, height=350, width=700) + restart_session() - with c3: - st.write("") - st.write("") - encrypted_x = st.session_state['encrypted_position'][0] - encrypted_y = st.session_state['encrypted_position'][1] - st.write(f"Received starting point: ({display_encrypted(encrypted_x)},{display_encrypted(encrypted_y)})") - st.write(f"The encrypted result {display_encrypted(res)} is sent to the client.") diff --git a/NearestNeighbors/config.py b/NearestNeighbors/config.py index 8f1cb5e..0e1070c 100644 --- a/NearestNeighbors/config.py +++ b/NearestNeighbors/config.py @@ -4,5 +4,5 @@ restaurants_filepath = data_path / "restaurants.geojson" circuit_filepath = data_path / "circuit.zip" keys_filepath = data_path / "keys.zip" -total_restaurants_number = 15 +total_restaurants_number = 10 number_of_neighbors = 3 \ No newline at end of file diff --git a/NearestNeighbors/utils.py b/NearestNeighbors/utils.py index 618a0f1..cef8f87 100644 --- a/NearestNeighbors/utils.py +++ b/NearestNeighbors/utils.py @@ -1,31 +1,87 @@ -import pandas as pd from concrete import fhe -from config import circuit_filepath +from config import circuit_filepath, number_of_neighbors from shapely.geometry import Point import geopandas as gpd +import streamlit as st +import folium +from streamlit_folium import st_folium + +def init_session(): + """Initialize the Streamlit session and layout configuration. + + Returns: + Streamlit.columns: A tuple of Streamlit columns for layout customization. + """ + st.set_page_config(layout="wide") + + if 'markers' not in st.session_state: + st.session_state['markers'] = [] + if 'server_side' not in st.session_state: + st.session_state['server_side'] = [] + if 'client_side' not in st.session_state: + st.session_state['client_side'] = [] + + c1, c2, c3 = st.columns([1, 3, 1]) + + return c1, c2, c3 + def set_up_server(): + """Load a server instance from a specified circuit file + + Raises: + OSError: If there is an issue loading the FHE server. + + Returns: + concrete.fhe.compilation.server.Server: A server instance loaded from the circuit file. + """ try: server = fhe.Server.load(circuit_filepath) - except Exception: - raise Exception(f"Something went wrong with the circuit. Make sure that the circuit exists in {circuit_filepath}. If not run python generate_circuit.py.") + except OSError as e: + raise OSError(f"Something went wrong with the circuit. Make sure that the circuit \ + exists in {circuit_filepath}.If not run python generate_circuit.py.") from e return server def set_up_client(serialized_client_specs): - # Setting up client + """Generate a client instance from a specified circuit file + + Args: + serialized_client_specs (bytes): A serialized client specs + + Returns: + concrete.fhe.compilation.client.Client: A client instance created from the client specs + """ + client_specs = fhe.ClientSpecs.deserialize(serialized_client_specs) client = fhe.Client(client_specs) return client def display_encrypted(encrypted_object): + """Display a truncated representation of an encrypted object as a hexadecimal string + + Args: + encrypted_object (bytes): A serialized encrypted object to display + + Returns: + str: A truncated hexadecimal representation of the encrypted object + """ encoded_text = encrypted_object.hex() res = '...' + encoded_text[-10:] return res def transform_point(longitude, latitude): + """Transform coordinates into an integer to be processed by the FHE circuit + + Args: + longitude (float): longitude of the point + latitude (float): latitude of the point + + Returns: + int, int: integers to be processed by the FHE circuit + """ gdf = gpd.GeoDataFrame({'geometry': [Point(longitude, latitude)]}, crs='EPSG:4326') gdf = gdf.to_crs('EPSG:2154') x, y = gdf.geometry.iloc[0].x, gdf.geometry.iloc[0].y @@ -36,13 +92,101 @@ def transform_point(longitude, latitude): def process_result(rest, result): - processed_result = [] - restaurants_list = [] - for res in result: + """Add the nearest restaurants in the map and in the client view + + Args: + rest (geopandas.DataFrame): list of restaurants + result (list[(int, int)]): list of the nearest neighbors returned by the algorithm + """ + add_to_client_side(f"The {number_of_neighbors} closest restaurant to your location are:") + for index, res in enumerate(result): mask1 = rest['geometry'].to_crs("epsg:2154").apply(lambda geom: int(geom.x) % 10000 == res[0]) mask2 = rest['geometry'].to_crs("epsg:2154").apply(lambda geom: int(geom.y) % 10000 == res[1]) final_mask = mask1 & mask2 result_df = rest[final_mask] - processed_result.append(result_df.geometry) - restaurants_list.append(f"{result_df.name.iloc[0]}, {result_df.cuisine.iloc[0]}") - return processed_result, restaurants_list \ No newline at end of file + restaurant_info = f"{result_df.name.iloc[0]}, {result_df.cuisine.iloc[0]}" + add_marker(result_df.geometry, restaurant_info) + add_to_client_side(f"{index+1}. {restaurant_info}.") + + +def add_marker(coordinates, name): + """Add a marker with coordinates and a name to the Streamlit session. + + Args: + coordinates (Point): The coordinates of the marker + name (str): The name or label for the marker + """ + data = {'coordinates': coordinates, 'name': name} + st.session_state['markers'].append(data) + +def display_map(restaurants, returned_objects=None): + """Display the map with nodes and optional markers and paths. + + Args: + nodes (geopandas.DataFrame): A dataframe containing the nodes to display + returned_objects (List[str], optional): Objects to be returned when interacting with the map. Defaults to None. + + Returns: + Streamlit.FoliumMap: An interactive map displaying nodes and markers + """ + m = restaurants.explore( + scheme="naturalbreaks", + tooltip="name", + popup=["name"], + name="Quadratic-Paris", + color="red", + marker_kwds=dict(radius=5, fill=True, name='node_id'), + ) + + if 'position' in st.session_state: + position = st.session_state['position'] + folium.Marker([position.y, position.x], popup='Starting point', tooltip='Starting point').add_to(m) + + if 'markers' in st.session_state: + for mrk in st.session_state['markers']: + folium.Marker([mrk['coordinates'].y, mrk['coordinates'].x], popup=mrk['name'], tooltip=mrk['name'], + icon=folium.Icon(color='black',icon_color='#FFFF00')).add_to(m) + + + return st_folium(m, width=725, key="origin", returned_objects=returned_objects) + +def add_to_server_side(message): + """Add a message to the server side of the view + + Args: + message (str): The message to be added to the server side + """ + st.session_state['server_side'].append(message) + +def add_to_client_side(message): + """Add a message to the client side of the view + + Args: + message (str): The message to be added to the client side + """ + st.session_state['client_side'].append(message) + +def display_server_side(): + """Display the messages stored in the server-side view. + """ + st.write("**Server-side**") + for message in st.session_state['server_side']: + st.write(message) + +def display_client_side(): + """Display the messages stored in the client-side view. + """ + st.write("**Client-side**") + for message in st.session_state['client_side']: + st.write(message) + + +def restart_session(): + """Clear the session state to restart + """ + if st.button('Restart'): + for key in st.session_state.items(): + if key[0] != 'evaluation_key': + del st.session_state[key[0]] + st.rerun() + From ba860bae608fa69b75e3c985bc2b98158fe6053b Mon Sep 17 00:00:00 2001 From: Riad15l Date: Wed, 13 Dec 2023 14:00:16 +0100 Subject: [PATCH 6/9] REFACTOR: clean code --- NearestNeighbors/app.py | 152 +++++++----- NearestNeighbors/config.py | 4 +- NearestNeighbors/generate_circuit.py | 29 +-- NearestNeighbors/knn.py | 88 ------- NearestNeighbors/network.py | 16 +- NearestNeighbors/requirements.txt | 334 +++++++++++++++++++++++++++ NearestNeighbors/utils.py | 131 +++++++---- 7 files changed, 528 insertions(+), 226 deletions(-) delete mode 100644 NearestNeighbors/knn.py create mode 100644 NearestNeighbors/requirements.txt diff --git a/NearestNeighbors/app.py b/NearestNeighbors/app.py index dc5e057..52ec53a 100644 --- a/NearestNeighbors/app.py +++ b/NearestNeighbors/app.py @@ -1,12 +1,21 @@ import network -from utils import set_up_server, set_up_client, display_encrypted, transform_point, process_result from shapely.geometry import Point import streamlit as st -from shapely.geometry import Point -import config -from utils import set_up_server, set_up_client, display_encrypted, add_marker,\ - display_map, init_session, add_to_server_side, add_to_client_side, display_client_side,\ - display_server_side, restart_session +import config +from utils import ( + set_up_server, + set_up_client, + display_encrypted, + transform_point, + process_result, + display_map, + init_session, + add_to_server_side, + add_to_client_side, + display_client_side, + display_server_side, + restart_session, +) from concrete import fhe server = set_up_server() @@ -23,86 +32,109 @@ display_server_side() - # keys generation view -if 'evaluation_key' not in st.session_state: +if "evaluation_key" not in st.session_state: with c1: - if st.button('Generate keys'): - with st.spinner('Generating keys'): - client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) - st.session_state['evaluation_key'] = client.evaluation_keys.serialize() - add_to_client_side("Encryption/decryption keys and evaluation keys are generated.") + if st.button("Generate keys"): + with st.spinner("Generating keys"): + client.keys.load_if_exists_generate_and_save_otherwise( + config.keys_filepath + ) + st.session_state["evaluation_key"] = client.evaluation_keys.serialize() + add_to_client_side( + "Encryption/decryption keys and evaluation keys are generated." + ) add_to_client_side("The evaluation key is sent to the server.") - add_to_server_side(f"Evaluation key: {display_encrypted(st.session_state['evaluation_key'])}") + add_to_server_side( + f"Evaluation key: {display_encrypted(st.session_state['evaluation_key'])}" + ) st.rerun() else: - - if 'position' not in st.session_state : + if "position" not in st.session_state: with c1: st.write("Select your starting position on the st_map") if st_map.get("last_clicked"): - position = Point(st_map.get("last_clicked")["lng"], st_map.get("last_clicked")["lat"]) - add_to_client_side(f"Selected starting point is: ({position.x}, {position.y})") - st.session_state['position'] = position + position = Point( + st_map.get("last_clicked")["lng"], st_map.get("last_clicked")["lat"] + ) + add_to_client_side( + f"Selected starting point is: ({position.x}, {position.y})" + ) + st.session_state["position"] = position st.rerun() - - if 'position' in st.session_state and 'encrypted_position' not in st.session_state : - - position = st.session_state['position'] + + if "position" in st.session_state and "encrypted_position" not in st.session_state: + position = st.session_state["position"] with c1: - - if st.button('Encrypt and send inputs'): - with st.spinner('Encrypting inputs'): + if st.button("Encrypt and send inputs"): + with st.spinner("Encrypting inputs"): client.keys.load(config.keys_filepath) x, y = transform_point(position.x, position.y) encrypted_x, encrypted_y = client.encrypt(x, y) - encrypted_position = (encrypted_x.serialize(), encrypted_y.serialize()) - add_to_server_side(f"Received starting point: ({display_encrypted(encrypted_position[0])},{display_encrypted(encrypted_position[1])})") - st.session_state['encrypted_position'] = encrypted_position + encrypted_position = ( + encrypted_x.serialize(), + encrypted_y.serialize(), + ) + add_to_server_side( + f"Received starting point: ({display_encrypted(encrypted_position[0])},{display_encrypted(encrypted_position[1])})" + ) + st.session_state["encrypted_position"] = encrypted_position st.rerun() - - if 'encrypted_position' in st.session_state and 'encrypted_results' not in st.session_state: - + + if ( + "encrypted_position" in st.session_state + and "encrypted_results" not in st.session_state + ): with c3: - encrypted_x = st.session_state['encrypted_position'][0] - encrypted_y = st.session_state['encrypted_position'][1] - - if st.button(f'Find {config.number_of_neighbors} nearest neighbors'): - with st.spinner('Computing'): + encrypted_x = st.session_state["encrypted_position"][0] + encrypted_y = st.session_state["encrypted_position"][1] + + if st.button(f"Find {config.number_of_neighbors} nearest restaurants"): + with st.spinner("Computing"): + st.write("Please wait, it may take few minutes.") deserialized_x = fhe.Value.deserialize(encrypted_x) deserialized_y = fhe.Value.deserialize(encrypted_y) - deserialized_evaluation_keys = fhe.EvaluationKeys.deserialize(st.session_state['evaluation_key']) - client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) - res = server.run(deserialized_x, deserialized_y, evaluation_keys=deserialized_evaluation_keys) + deserialized_evaluation_keys = fhe.EvaluationKeys.deserialize( + st.session_state["evaluation_key"] + ) + client.keys.load_if_exists_generate_and_save_otherwise( + config.keys_filepath + ) + res = server.run( + deserialized_x, + deserialized_y, + evaluation_keys=deserialized_evaluation_keys, + ) ser_res = fhe.Value.serialize(res) - add_to_server_side(f"The encrypted result {display_encrypted(ser_res)} is sent to the client.") - add_to_client_side(f"The received encrypted result is: {display_encrypted(ser_res)}.") - st.session_state['encrypted_results'] = ser_res + add_to_server_side( + f"The encrypted result {display_encrypted(ser_res)} is sent to the client." + ) + add_to_client_side( + f"The received encrypted result is: {display_encrypted(ser_res)}." + ) + st.session_state["encrypted_results"] = ser_res st.rerun() - - if 'encrypted_results' in st.session_state and 'decrypted_result' not in st.session_state : - res = st.session_state['encrypted_results'] + + if ( + "encrypted_results" in st.session_state + and "decrypted_result" not in st.session_state + ): + res = st.session_state["encrypted_results"] with c1: - if st.button('Decrypt result'): - with st.spinner('Computing'): - client.keys.load_if_exists_generate_and_save_otherwise(config.keys_filepath) + if st.button("Decrypt result"): + with st.spinner("Computing"): + client.keys.load_if_exists_generate_and_save_otherwise( + config.keys_filepath + ) deser_res = fhe.Value.deserialize(res) decrypted_result = client.decrypt(deser_res) process_result(restaurants, decrypted_result) - st.session_state['decrypted_result'] = decrypted_result + st.session_state["decrypted_result"] = decrypted_result st.rerun() - - - if 'decrypted_result' in st.session_state: - # for index, enc_res in enumerate(processed_result): - # # folium.Marker([enc_res.y, enc_res.x], popup=restaurants_list[index], tooltip=restaurants_list[index], - # # icon=folium.Icon(color='black',icon_color='#FFFF00')).add_to(m) - # add_marker(Point(enc_res.y, enc_res.x), restaurants_list[index]) - # st.rerun() + + if "decrypted_result" in st.session_state: with c1: restart_session() - - diff --git a/NearestNeighbors/config.py b/NearestNeighbors/config.py index 0e1070c..4b8cac9 100644 --- a/NearestNeighbors/config.py +++ b/NearestNeighbors/config.py @@ -4,5 +4,5 @@ restaurants_filepath = data_path / "restaurants.geojson" circuit_filepath = data_path / "circuit.zip" keys_filepath = data_path / "keys.zip" -total_restaurants_number = 10 -number_of_neighbors = 3 \ No newline at end of file +total_restaurants_number = 7 +number_of_neighbors = 2 diff --git a/NearestNeighbors/generate_circuit.py b/NearestNeighbors/generate_circuit.py index d9f033a..8bd8f25 100644 --- a/NearestNeighbors/generate_circuit.py +++ b/NearestNeighbors/generate_circuit.py @@ -1,6 +1,6 @@ import numpy as np from concrete import fhe -import config +import config import network _, point_coordinates = network.get() @@ -12,7 +12,7 @@ def get_point(index): - return (points[2*index], points[2*index + 1]) + return (points[2 * index], points[2 * index + 1]) def all_distances(x, y): @@ -23,17 +23,17 @@ def all_distances(x, y): return a + b - # TLUs relu = fhe.univariate(lambda x: x if x > 0 else 0) is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0) -arg_selection = fhe.univariate(lambda x: (x-1)//2 if x % 2 else 0) # relu packed with a flag (alternating between 0 and relu) - +arg_selection = fhe.univariate( + lambda x: (x - 1) // 2 if x % 2 else 0 +) # relu packed with a flag (alternating between 0 and relu) def swap(this_idx, this_dist, that_idx, that_dist): """ - Swaps this and that if this > that. + Swaps this and that if this > that. We must pass both the index and the distance for both this and that. Returns: @@ -44,7 +44,7 @@ def swap(this_idx, this_dist, that_idx, that_dist): dist = relu(diff) idx_min = this_idx - idx - idx_max = that_idx + idx + idx_max = that_idx + idx dist_min = this_dist - dist dist_max = that_dist + dist return fhe.array([idx_min, dist_min, idx_max, dist_max]) @@ -55,19 +55,14 @@ def knn(x, y): dist = all_distances(x, y) idx = list(range(N_PTS)) for k in range(config.number_of_neighbors): - for i in range(k+1, N_PTS): - idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i]) + for i in range(k + 1, N_PTS): + idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i]) return fhe.array([get_point(idx[j]) for j in range(config.number_of_neighbors)]) -inputset = [ - (1550, 4289), - (1908, 3972), - (1705, 4253), - (1980, 4071) - ] +inputset = [(1550, 4289), (1908, 3972), (1705, 4253), (1980, 4071)] + - circuit = knn.compile(inputset) -circuit.server.save(config.circuit_filepath) \ No newline at end of file +circuit.server.save(config.circuit_filepath) diff --git a/NearestNeighbors/knn.py b/NearestNeighbors/knn.py deleted file mode 100644 index 5608205..0000000 --- a/NearestNeighbors/knn.py +++ /dev/null @@ -1,88 +0,0 @@ -from concrete import fhe -import numpy - - -# TLUs -relu = fhe.univariate(lambda x: x if x > 0 else 0) -is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0) -arg_selection = fhe.univariate(lambda x: (x-1)//2 if x % 2 else 0) - -# Database of Points of Interests -points_array = numpy.array([ - [2, 3], [1, 5], [3, 2], [5, 2], [1, 1], - [9, 4], [13, 2], [14, 13], [9, 8], [8, 0], - [2, 10], [3, 8], [8, 12], [4, 10], [7, 7], -]) -N_PTS = points_array.shape[0] -points = fhe.LookupTable(points_array.flatten()) - - -def get_point(index: int): - return (points[2*index], points[2*index + 1]) - - -def all_distances(x: int, y: int): - """ - Computes distances to all points of interests (POI). - - Arguments: - x, y: coordinates of center - - Returns: - array of distances (int) to POI. - """ - xs = numpy.arange(0, 2 * N_PTS, 2) - ys = numpy.arange(1, 2 * N_PTS, 2) - a = abs(points[xs] - x) - b = abs(points[ys] - y) - return a + b - - -def swap(this_idx, this_dist, that_idx, that_dist): - """ - Swaps this and that if this > that. - We must pass both the index and the distance for both this and that. - - Returns: - idxmin, min, idxmax, max of this and that based on distance - """ - diff = this_dist - that_dist - idx = arg_selection(2 * (this_idx - that_idx) + is_positive(diff)) - dist = relu(diff) - - idx_min = this_idx - idx - idx_max = that_idx + idx - dist_min = this_dist - dist - dist_max = that_dist + dist - return fhe.array([idx_min, dist_min, idx_max, dist_max]) - - -@fhe.compiler({"x": "encrypted", "y": "encrypted"}) -def knn(x, y): - dist = [distance((x, y), get_point(i)) for i in range(N_PTS)] - idx = list(range(N_PTS)) - for k in range(2): - for i in range(k+1, N_PTS): - idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i]) - return fhe.array([get_point(idx[j]) for j in range(2)]) - - -inputset = [(4, 3), (0, 0), (7, 3), (4, 7)] - -circuit = knn.compile(inputset) -circuit.client.keys.generate() - - -def nearest(x, y): - """ - Privately get nearest points of interest (POI). - - Arguments: - x, y: coordinates of the center - - Returns: - Kx2 array of coordinates of neareast POI. - """ - ex, ey = circuit.encrypt(x, y) - res = circuit.run(ex, ey) - return circuit.decrypt(res) \ No newline at end of file diff --git a/NearestNeighbors/network.py b/NearestNeighbors/network.py index ae16960..3dbb51a 100644 --- a/NearestNeighbors/network.py +++ b/NearestNeighbors/network.py @@ -3,16 +3,16 @@ import numpy - def get(): all_restaurants = gpd.read_file(config.restaurants_filepath) - all_restaurants = all_restaurants.dropna(subset=["name","cuisine"]) - sub_restaurants = all_restaurants.sample(config.total_restaurants_number, random_state = 42) - point_coordinates = [list([int(point.coords[0][0])%10000, - int(point.coords[0][1])%10000]) - for point in sub_restaurants['geometry'].to_crs("epsg:2154")] + all_restaurants = all_restaurants.dropna(subset=["name", "cuisine"]) + sub_restaurants = all_restaurants.sample( + config.total_restaurants_number, random_state=42 + ) + point_coordinates = [ + list([int(point.coords[0][0]) % 10000, int(point.coords[0][1]) % 10000]) + for point in sub_restaurants["geometry"].to_crs("epsg:2154") + ] point_coordinates = numpy.array(point_coordinates) return sub_restaurants, point_coordinates - - diff --git a/NearestNeighbors/requirements.txt b/NearestNeighbors/requirements.txt new file mode 100644 index 0000000..3c23eb5 --- /dev/null +++ b/NearestNeighbors/requirements.txt @@ -0,0 +1,334 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile requirements.in +# +altair==5.1.1 + # via streamlit +asttokens==2.4.0 + # via stack-data +attrs==23.1.0 + # via + # fiona + # jsonschema + # referencing +backcall==0.2.0 + # via ipython +blinker==1.6.2 + # via streamlit +branca==0.6.0 + # via + # folium + # streamlit-folium +cachetools==5.3.1 + # via streamlit +certifi==2023.7.22 + # via + # fiona + # pyproj + # requests +charset-normalizer==3.2.0 + # via requests +click==8.1.7 + # via + # click-plugins + # cligj + # fiona + # streamlit +click-plugins==1.1.1 + # via fiona +cligj==0.7.2 + # via fiona +cmake==3.27.5 + # via triton +comm==0.1.4 + # via ipykernel +concrete-python==2.0.0 + # via -r requirements.in +contourpy==1.1.1 + # via matplotlib +cycler==0.11.0 + # via matplotlib +debugpy==1.8.0 + # via ipykernel +decorator==5.1.1 + # via ipython +exceptiongroup==1.1.3 + # via ipython +executing==1.2.0 + # via stack-data +filelock==3.12.4 + # via + # torch + # triton +fiona==1.9.4.post1 + # via geopandas +folium==0.14.0 + # via + # -r requirements.in + # streamlit-folium +fonttools==4.42.1 + # via matplotlib +geopandas==0.14.0 + # via -r requirements.in +gitdb==4.0.10 + # via gitpython +gitpython==3.1.37 + # via streamlit +idna==3.4 + # via requests +importlib-metadata==6.8.0 + # via streamlit +ipykernel==6.25.2 + # via -r requirements.in +ipython==8.15.0 + # via ipykernel +jedi==0.19.0 + # via ipython +jinja2==3.1.2 + # via + # altair + # branca + # folium + # pydeck + # streamlit-folium + # torch +joblib==1.3.2 + # via scikit-learn +jsonschema==4.19.1 + # via altair +jsonschema-specifications==2023.7.1 + # via jsonschema +jupyter-client==8.3.1 + # via ipykernel +jupyter-core==5.3.1 + # via + # ipykernel + # jupyter-client +kiwisolver==1.4.5 + # via matplotlib +lit==16.0.6 + # via triton +mapclassify==2.6.0 + # via -r requirements.in +markdown-it-py==3.0.0 + # via rich +markupsafe==2.1.3 + # via jinja2 +matplotlib==3.8.0 + # via -r requirements.in +matplotlib-inline==0.1.6 + # via + # ipykernel + # ipython +mdurl==0.1.2 + # via markdown-it-py +mpmath==1.3.0 + # via sympy +nest-asyncio==1.5.8 + # via ipykernel +networkx==3.1 + # via + # concrete-python + # mapclassify + # torch +numpy==1.26.0 + # via + # -r requirements.in + # altair + # concrete-python + # contourpy + # folium + # mapclassify + # matplotlib + # pandas + # pyarrow + # pydeck + # scikit-learn + # scipy + # shapely + # streamlit +nvidia-cublas-cu11==11.10.3.66 + # via + # nvidia-cudnn-cu11 + # nvidia-cusolver-cu11 + # torch +nvidia-cuda-cupti-cu11==11.7.101 + # via torch +nvidia-cuda-nvrtc-cu11==11.7.99 + # via torch +nvidia-cuda-runtime-cu11==11.7.99 + # via torch +nvidia-cudnn-cu11==8.5.0.96 + # via torch +nvidia-cufft-cu11==10.9.0.58 + # via torch +nvidia-curand-cu11==10.2.10.91 + # via torch +nvidia-cusolver-cu11==11.4.0.1 + # via torch +nvidia-cusparse-cu11==11.7.4.91 + # via torch +nvidia-nccl-cu11==2.14.3 + # via torch +nvidia-nvtx-cu11==11.7.91 + # via torch +packaging==23.1 + # via + # altair + # geopandas + # ipykernel + # matplotlib + # streamlit +pandas==2.1.1 + # via + # altair + # geopandas + # mapclassify + # streamlit +parso==0.8.3 + # via jedi +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython +pillow==9.5.0 + # via + # matplotlib + # streamlit +platformdirs==3.10.0 + # via jupyter-core +prompt-toolkit==3.0.39 + # via ipython +protobuf==4.24.3 + # via streamlit +psutil==5.9.5 + # via ipykernel +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.2 + # via stack-data +pyarrow==13.0.0 + # via streamlit +pydeck==0.8.1b0 + # via streamlit +pygments==2.16.1 + # via + # ipython + # rich +pyparsing==3.1.1 + # via matplotlib +pyproj==3.6.1 + # via geopandas +python-dateutil==2.8.2 + # via + # jupyter-client + # matplotlib + # pandas + # streamlit +pytz==2023.3.post1 + # via pandas +pyzmq==25.1.1 + # via + # ipykernel + # jupyter-client +referencing==0.30.2 + # via + # jsonschema + # jsonschema-specifications +requests==2.31.0 + # via + # folium + # streamlit +rich==13.5.3 + # via streamlit +rpds-py==0.10.3 + # via + # jsonschema + # referencing +scikit-learn==1.3.1 + # via mapclassify +scipy==1.11.2 + # via + # concrete-python + # mapclassify + # scikit-learn +shapely==2.0.1 + # via + # -r requirements.in + # geopandas +six==1.16.0 + # via + # asttokens + # fiona + # python-dateutil +smmap==5.0.1 + # via gitdb +stack-data==0.6.2 + # via ipython +streamlit==1.27.0 + # via + # -r requirements.in + # streamlit-folium +streamlit-folium==0.14.0 + # via -r requirements.in +sympy==1.12 + # via torch +tenacity==8.2.3 + # via streamlit +threadpoolctl==3.2.0 + # via scikit-learn +toml==0.10.2 + # via streamlit +toolz==0.12.0 + # via altair +torch==2.0.1 + # via + # concrete-python + # triton +tornado==6.3.3 + # via + # ipykernel + # jupyter-client + # streamlit +traitlets==5.10.0 + # via + # comm + # ipykernel + # ipython + # jupyter-client + # jupyter-core + # matplotlib-inline +triton==2.0.0 + # via torch +typing-extensions==4.8.0 + # via + # altair + # streamlit + # torch +tzdata==2023.3 + # via pandas +tzlocal==5.0.1 + # via streamlit +urllib3==2.0.5 + # via requests +validators==0.22.0 + # via streamlit +watchdog==3.0.0 + # via streamlit +wcwidth==0.2.6 + # via prompt-toolkit +wheel==0.41.2 + # via + # nvidia-cublas-cu11 + # nvidia-cuda-cupti-cu11 + # nvidia-cuda-runtime-cu11 + # nvidia-curand-cu11 + # nvidia-cusparse-cu11 + # nvidia-nvtx-cu11 +zipp==3.17.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/NearestNeighbors/utils.py b/NearestNeighbors/utils.py index cef8f87..5a0dc82 100644 --- a/NearestNeighbors/utils.py +++ b/NearestNeighbors/utils.py @@ -6,6 +6,7 @@ import folium from streamlit_folium import st_folium + def init_session(): """Initialize the Streamlit session and layout configuration. @@ -13,13 +14,13 @@ def init_session(): Streamlit.columns: A tuple of Streamlit columns for layout customization. """ st.set_page_config(layout="wide") - - if 'markers' not in st.session_state: - st.session_state['markers'] = [] - if 'server_side' not in st.session_state: - st.session_state['server_side'] = [] - if 'client_side' not in st.session_state: - st.session_state['client_side'] = [] + + if "markers" not in st.session_state: + st.session_state["markers"] = [] + if "server_side" not in st.session_state: + st.session_state["server_side"] = [] + if "client_side" not in st.session_state: + st.session_state["client_side"] = [] c1, c2, c3 = st.columns([1, 3, 1]) @@ -38,11 +39,14 @@ def set_up_server(): try: server = fhe.Server.load(circuit_filepath) except OSError as e: - raise OSError(f"Something went wrong with the circuit. Make sure that the circuit \ - exists in {circuit_filepath}.If not run python generate_circuit.py.") from e + raise OSError( + f"Something went wrong with the circuit. Make sure that the circuit \ + exists in {circuit_filepath}.If not run python generate_circuit.py." + ) from e return server + def set_up_client(serialized_client_specs): """Generate a client instance from a specified circuit file @@ -52,12 +56,13 @@ def set_up_client(serialized_client_specs): Returns: concrete.fhe.compilation.client.Client: A client instance created from the client specs """ - + client_specs = fhe.ClientSpecs.deserialize(serialized_client_specs) client = fhe.Client(client_specs) return client + def display_encrypted(encrypted_object): """Display a truncated representation of an encrypted object as a hexadecimal string @@ -68,7 +73,7 @@ def display_encrypted(encrypted_object): str: A truncated hexadecimal representation of the encrypted object """ encoded_text = encrypted_object.hex() - res = '...' + encoded_text[-10:] + res = "..." + encoded_text[-10:] return res @@ -82,8 +87,8 @@ def transform_point(longitude, latitude): Returns: int, int: integers to be processed by the FHE circuit """ - gdf = gpd.GeoDataFrame({'geometry': [Point(longitude, latitude)]}, crs='EPSG:4326') - gdf = gdf.to_crs('EPSG:2154') + gdf = gpd.GeoDataFrame({"geometry": [Point(longitude, latitude)]}, crs="EPSG:4326") + gdf = gdf.to_crs("EPSG:2154") x, y = gdf.geometry.iloc[0].x, gdf.geometry.iloc[0].y x = int(x) % 10000 y = int(y) % 10000 @@ -98,10 +103,20 @@ def process_result(rest, result): rest (geopandas.DataFrame): list of restaurants result (list[(int, int)]): list of the nearest neighbors returned by the algorithm """ - add_to_client_side(f"The {number_of_neighbors} closest restaurant to your location are:") + add_to_client_side( + f"The {number_of_neighbors} closest restaurant to your location are:" + ) for index, res in enumerate(result): - mask1 = rest['geometry'].to_crs("epsg:2154").apply(lambda geom: int(geom.x) % 10000 == res[0]) - mask2 = rest['geometry'].to_crs("epsg:2154").apply(lambda geom: int(geom.y) % 10000 == res[1]) + mask1 = ( + rest["geometry"] + .to_crs("epsg:2154") + .apply(lambda geom: int(geom.x) % 10000 == res[0]) + ) + mask2 = ( + rest["geometry"] + .to_crs("epsg:2154") + .apply(lambda geom: int(geom.y) % 10000 == res[1]) + ) final_mask = mask1 & mask2 result_df = rest[final_mask] restaurant_info = f"{result_df.name.iloc[0]}, {result_df.cuisine.iloc[0]}" @@ -113,11 +128,12 @@ def add_marker(coordinates, name): """Add a marker with coordinates and a name to the Streamlit session. Args: - coordinates (Point): The coordinates of the marker + coordinates (Point): The coordinates of the marker name (str): The name or label for the marker """ - data = {'coordinates': coordinates, 'name': name} - st.session_state['markers'].append(data) + data = {"coordinates": coordinates, "name": name} + st.session_state["markers"].append(data) + def display_map(restaurants, returned_objects=None): """Display the map with nodes and optional markers and paths. @@ -129,34 +145,49 @@ def display_map(restaurants, returned_objects=None): Returns: Streamlit.FoliumMap: An interactive map displaying nodes and markers """ - m = restaurants.explore( - scheme="naturalbreaks", - tooltip="name", - popup=["name"], - name="Quadratic-Paris", - color="red", - marker_kwds=dict(radius=5, fill=True, name='node_id'), - ) - - if 'position' in st.session_state: - position = st.session_state['position'] - folium.Marker([position.y, position.x], popup='Starting point', tooltip='Starting point').add_to(m) - - if 'markers' in st.session_state: - for mrk in st.session_state['markers']: - folium.Marker([mrk['coordinates'].y, mrk['coordinates'].x], popup=mrk['name'], tooltip=mrk['name'], - icon=folium.Icon(color='black',icon_color='#FFFF00')).add_to(m) + if "decrypted_result" in st.session_state: + m = restaurants.explore( + scheme="naturalbreaks", + tooltip="name", + popup=["name"], + name="Quadratic-Paris", + color="red", + marker_kwds=dict(radius=5, fill=True, name="node_id"), + ) + else: + m = restaurants.explore( + scheme="naturalbreaks", + tooltip="name", + popup=["name"], + name="Quadratic-Paris", + ) + + if "position" in st.session_state: + position = st.session_state["position"] + folium.Marker( + [position.y, position.x], popup="Starting point", tooltip="Starting point" + ).add_to(m) + + if "markers" in st.session_state: + for mrk in st.session_state["markers"]: + folium.Marker( + [mrk["coordinates"].y, mrk["coordinates"].x], + popup=mrk["name"], + tooltip=mrk["name"], + icon=folium.Icon(color="black", icon_color="#FFFF00"), + ).add_to(m) - return st_folium(m, width=725, key="origin", returned_objects=returned_objects) + def add_to_server_side(message): """Add a message to the server side of the view Args: message (str): The message to be added to the server side """ - st.session_state['server_side'].append(message) + st.session_state["server_side"].append(message) + def add_to_client_side(message): """Add a message to the client side of the view @@ -164,29 +195,27 @@ def add_to_client_side(message): Args: message (str): The message to be added to the client side """ - st.session_state['client_side'].append(message) + st.session_state["client_side"].append(message) + def display_server_side(): - """Display the messages stored in the server-side view. - """ + """Display the messages stored in the server-side view.""" st.write("**Server-side**") - for message in st.session_state['server_side']: + for message in st.session_state["server_side"]: st.write(message) + def display_client_side(): - """Display the messages stored in the client-side view. - """ + """Display the messages stored in the client-side view.""" st.write("**Client-side**") - for message in st.session_state['client_side']: + for message in st.session_state["client_side"]: st.write(message) def restart_session(): - """Clear the session state to restart - """ - if st.button('Restart'): + """Clear the session state to restart""" + if st.button("Restart"): for key in st.session_state.items(): - if key[0] != 'evaluation_key': + if key[0] != "evaluation_key": del st.session_state[key[0]] - st.rerun() - + st.rerun() From 17d74826b17dea9820aa61c4887fdefc24f4ce14 Mon Sep 17 00:00:00 2001 From: Riad15l Date: Wed, 31 Jan 2024 16:47:57 +0100 Subject: [PATCH 7/9] DOC+FIX: add readme and some updates reported in the pull request --- NearestNeighbors/Getting-Started.ipynb | 331 +++++++++++++++++++++++++ NearestNeighbors/NearestExample.ipynb | 194 --------------- NearestNeighbors/README.MD | 133 ++++++++++ NearestNeighbors/figures/exec_time.png | Bin 0 -> 8239 bytes NearestNeighbors/generate_circuit.py | 6 +- 5 files changed, 466 insertions(+), 198 deletions(-) create mode 100644 NearestNeighbors/Getting-Started.ipynb delete mode 100644 NearestNeighbors/NearestExample.ipynb create mode 100644 NearestNeighbors/README.MD create mode 100644 NearestNeighbors/figures/exec_time.png diff --git a/NearestNeighbors/Getting-Started.ipynb b/NearestNeighbors/Getting-Started.ipynb new file mode 100644 index 0000000..72a2a94 --- /dev/null +++ b/NearestNeighbors/Getting-Started.ipynb @@ -0,0 +1,331 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Nearest Neighbors Example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Server's Data Setup\n", + "The server owns coordinates to points of interest like restaurants and commerces. The coordinates are kept in a LookupTable" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/riad/envs/zama/lib/python3.9/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "from concrete import fhe\n", + "import numpy\n", + "\n", + "\n", + "# Database of Points of Interests\n", + "points_array = numpy.array([\n", + " [2, 3], [1, 5], [3, 2], [5, 2], [1, 1],\n", + " [9, 4], [13, 2], [14, 13], [9, 8], [8, 0],\n", + " [2, 10], [3, 8], [8, 12], [4, 10], [7, 7],\n", + "])\n", + "N_PTS = points_array.shape[0]\n", + "points = fhe.LookupTable(points_array.flatten())\n", + "\n", + "\n", + "def get_point(index):\n", + " return (points[2*index], points[2*index + 1])\n", + "\n", + "\n", + "def all_distances(x, y):\n", + " xs = numpy.arange(0, 2 * N_PTS, 2)\n", + " ys = numpy.arange(1, 2 * N_PTS, 2)\n", + " a = abs(points[xs] - x)\n", + " b = abs(points[ys] - y)\n", + " return a + b" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We use swap sort to find the $K$ nearest points to a given point. However, we are interested in the indices of the elements, not just their distances. We must therefore work on tuples of index and distance, effectively implementing numpy argpartition." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# TLUs\n", + "relu = fhe.univariate(lambda x: x if x > 0 else 0)\n", + "is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0)\n", + "odd_halving = fhe.univariate(lambda x: (x-1)//2 if x % 2 else 0) " + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "def swap(this_idx, this_dist, that_idx, that_dist):\n", + " \"\"\"\n", + " Swaps this and that if this > that. \n", + " We must pass both the index and the distance for both this and that.\n", + "\n", + " Returns:\n", + " idxmin, min, idxmax, max of this and that based on distance\n", + " \"\"\"\n", + " diff = this_dist - that_dist\n", + " idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff))\n", + " dist = relu(diff)\n", + "\n", + " idx_min = this_idx - idx\n", + " idx_max = that_idx + idx \n", + " dist_min = this_dist - dist\n", + " dist_max = that_dist + dist\n", + " return fhe.array([idx_min, dist_min, idx_max, dist_max])\n", + "\n", + "\n", + "@fhe.compiler({\"x\": \"encrypted\", \"y\": \"encrypted\"})\n", + "def knn(x, y):\n", + " dist = all_distances(x, y)\n", + " idx = list(range(N_PTS))\n", + " for k in range(2):\n", + " for i in range(k+1, N_PTS):\n", + " idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i])\n", + " return fhe.array([get_point(idx[j]) for j in range(2)])\n", + "\n", + "\n", + "inputset = [(4, 3), (0, 0), (15, 3), (4, 15)]\n", + "\n", + "circuit = knn.compile(inputset)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Client\n", + "The client simply invokes the server's nearest neighbors circuit." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "5.48 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" + ] + } + ], + "source": [ + "%%timeit -r 1 -n 1\n", + "circuit.client.keys.generate()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "def nearest(x, y):\n", + " ex, ey = circuit.encrypt(x, y)\n", + " res = circuit.run(ex, ey) # Simulate request to the server\n", + " return circuit.decrypt(res)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Benchmarks" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "9.85 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" + ] + } + ], + "source": [ + "%%timeit -r 1 -n 1\n", + "nearest(4, 3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note: Extended benchmark results can be found in README.MD" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Unitary tests" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Suppose we have two values, this = 50 and that = 20, the swap function should swap the values and the indexes:\n", + "New index of this is 10\n", + "New value of this is 20\n", + "New index of this is 0\n", + "New value of this is 50\n", + "Now if we have , this = 5 and that = 20, the swap function should keep everythin unchanged:\n", + "New index of this is 0\n", + "New value of this is 5\n", + "New index of this is 10\n", + "New value of this is 20\n" + ] + } + ], + "source": [ + "# unitary test of swap function\n", + "print(\"Suppose we have two values, this = 50 and that = 20, the swap function should swap the values and the indexes:\")\n", + "this_idx = 0\n", + "this_dist = 50\n", + "that_idx = 10\n", + "that_dist = 20\n", + "this_idx, this_dist, that_idx, that_dist = swap(this_idx, this_dist, that_idx, that_dist)\n", + "\n", + "print(f\"New index of this is {this_idx}\")\n", + "print(f\"New value of this is {this_dist}\")\n", + "\n", + "print(f\"New index of this is {that_idx}\")\n", + "print(f\"New value of this is {that_dist}\")\n", + "\n", + "\n", + "\n", + "print(\"Now if we have , this = 5 and that = 20, the swap function should keep everythin unchanged:\")\n", + "this_idx = 0\n", + "this_dist = 5\n", + "that_idx = 10\n", + "that_dist = 20\n", + "this_idx, this_dist, that_idx, that_dist = swap(this_idx, this_dist, that_idx, that_dist)\n", + "\n", + "print(f\"New index of this is {this_idx}\")\n", + "print(f\"New value of this is {this_dist}\")\n", + "\n", + "print(f\"New index of this is {that_idx}\")\n", + "print(f\"New value of this is {that_dist}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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yJAh6b7m9evX2bp1q90+T91mx44dCQwMZMiQIQQEBLB3716WLVtm3eaPP/5Iu3btqFChAmPGjOHKlStMmzaNJk2aEB0dneGJPCv769y5c7Rr144nn3ySnj17UqpUKVJSUnjkkUfYuHEjzz77LNWqVWP37t18+OGHHDhwgMWLF1vXf+aZZ5g7dy7du3encePG/PTTT3To0CHd2OrWrZupzoELFizg8uXLDBw4kOLFixMVFcW0adM4fvw4CxYssKmbmWPeMAw6derExo0bef7556lWrRqLFi2iT58+t43lwQcf5KWXXmLq1Kn83//9H9WqVQOw/gvw559/0rVrV/r370+fPn349NNP6du3L/Xq1aN69eqApbWrefPmnDhxgueee46yZcvy66+/MnLkSOLi4jJ16eTxxx+nUqVKvPvuuxleort48SLNmjVj7969PP3009StW5e///6bJUuWcPz4ce65554018vscff888/z7bffMnjwYEJDQzl37hwbN25k79691K1bF7AkRO3ataNevXpERETg4uLC7Nmzeeihh9iwYQMNGzbM1LbeeOMNEhISOH78OB9++CFApjrEbty4kYULF/LCCy/g4+PD1KlTeeyxxzh69CjFixcH4PTp0zzwwAPWL9ISJUqwcuVK+vfvT2JiIkOHDk13+8uXL+eJJ56gZs2ajBs3jvPnz9O/f39Kly6dZv3bnYdSmc1mwsLCeOCBB5gwYQKrVq0iIiKCGzdu8NZbbwGW4/mRRx5h3bp19O/fnzp16rB69WpeffVVTpw4Yd1PqX766Se++eYbBg8ezD333EP58uUddm5LT1oxnjx5koYNG3LhwgWeffZZqlatyokTJ/j222+5fPky7u7u6W4rM8fe7t27efjhhylRogRjxozhxo0bREREpPv9V69ePQzD4Ndff82eDv5GATd79mwDMLZu3Wot69OnjwEYb731lk3d++67z6hXr571eWxsrAEYXl5exvHjx63lW7ZsMQDj5ZdftpY1b97caN68ud3r9+nTxyhXrpz1+dmzZw3AiIiIyFL89erVM65du2YtnzBhggEY33//vWEYhnHmzBnD3d3dePjhhw2z2WytN336dAMwPv3003RjSn2fxYsXN+Lj463l33//vQEYS5cutZYNGjTISOuwGjJkiOHr62vcuHEjU+8r1Z9//mkAxrRp02zK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DRo0oGfPntmyL+rVqwdYOq62bdsWV1dXnnzySZ555hni4+N56KGHKFOmDEeOHGHatGnUqVPntll5p06deOONN0hMTMTX1xewfDgjIyMJDw+nTp069OvXj8DAQPbt28cff/xh/QC9//77tGvXjkaNGtG/f3/rMFg/P78M5+fJjv3Vq1cvvvnmG55//nnWrVtHkyZNMJvN7Nu3j2+++YbVq1dTv3596tSpw1NPPcXHH39MQkICjRs3Zu3aten+It2+fTvx8fF06tQpw9evWrUqFStW5JVXXuHEiRP4+vry3Xff3VVTdnh4OE2aNOH111/n8OHDhIaGsnDhwkx/OdapUwdXV1fGjx9PQkICHh4ePPTQQ5QsWTLTMbz66qssWbKEjh07WodmX7p0id27d/Ptt99y+PDhbGuyfvXVV/n22295/PHHefrpp6lXrx7x8fEsWbKEGTNmULt27TTXy8xxl5SURJkyZejatSu1a9fG29ubH3/8ka1bt/LBBx8AluP8k08+oV27dlSvXp1+/fpRunRpTpw4wbp16/D19WXp0qWZ2hZYPp9ff/01w4YNo0GDBnh7e2fLRJPvvfce69at4/7772fAgAGEhoYSHx9PdHQ0P/74Y4bzhbz77rt06tSJJk2a0K9fP86fP8/06dOpUaNGml+gmeXp6cmqVavo06cP999/PytXrmT58uX83//9n7UvWHh4OC1btuSNN97g8OHD1K5dmx9++IHvv/+eoUOH2vVbS4ujzm1Z8e677/LDDz/QvHlz61QQcXFxLFiwgI0bN9p1Kk6NIzPHHljmPVu1ahXNmjXjhRde4MaNG0ybNo3q1avz+++/2217zZo1NGnSxHp57a5ly9gnJ5be8OsiRYrY1b11uFvqcMD333/f+OCDD4zg4GDDw8PDaNasmbFr1y679efOnWtUqFDBcHd3N+rUqWOsXr3abqizYRjGr7/+atSrV89wd3e/7VDs1Ph//vln49lnnzX8/f0Nb29vo0ePHjZDP1NNnz7dqFq1qlGoUCGjVKlSxsCBA+2G+KU3/Pr999+3296t8d24ccN48cUXjRIlShgmk8m6v7799lvj4YcfNkqWLGm4u7sbZcuWNZ577jkjLi4u3feW6vTp04abm5vxxRdf2C3buHGj0aZNG8PHx8coUqSIUatWLbvhjD/++KPRpEkTw8vLy/D19TXC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Rjo5c4pBVGw8Z5KSKenjScOQYri6mBwdlsNpv4iIFAwOTWR++eUX3n//fbZv305cXByLFi2ic+fO1uWGYRAREcGsWbO4cOECTZo0ITIykkqVKjku6Dxk1Z44xi6NIS4h2VoW6OdJRHgoYTUCHRiZY2m/iIgUHA69tHTp0iVq167NRx99lObyCRMmMHXqVGbMmMGWLVsoUqQIbdu2JTk5Oc36BcmqPXEMnBtt82UNcCohmYFzo1m1J85BkTmW9ouISMFiMgzDcHQQACaTyaZFxjAMgoKCGD58OK+88goACQkJlCpVijlz5vDkk09maruJiYn4+fmRkJCAr69vToWfq8wpBk3H/2T3ZZ3KBAT4ebJxxEMF6nKK9ouISP6R2e/vPNvZNzY2llOnTtG6dWtrmZ+fH/fffz+bN29Od72rV6+SmJho88hvomLj0/2yBjCAuIRkomLjcy+oPED7RUSk4MmzicypU6cAKFWqlE15qVKlrMvSMm7cOPz8/KyP4ODgHI3TEc4kZe7SWmbr5RfaLyIiBU+eTWTu1MiRI0lISLA+jh075uiQsl1JH89srZdfaL+IiBQ8eTaRCQgIAOD06dM25adPn7YuS4uHhwe+vr42j/ymYUgxAv08Sa+XhwnLKJ2GIcVyMyyH034RESl48mwiExISQkBAAGvXrrWWJSYmsmXLFho1auTAyBzP1cVERHgogN2XdurziPDQAtehVftFRKTgcWgic/HiRXbu3MnOnTsBSwffnTt3cvToUUwmE0OHDuU///kPS5YsYffu3fTu3ZugoCCbuWYKqrAagUT2rEuAn+1lkgA/TyJ71i2w86Vov4iIFCwOHX69fv16WrZsaVfep08f5syZY50Qb+bMmVy4cIGmTZvy8ccfU7ly5Uy/Rn4cfn0zzWCbNu0XERHnltnv7zwzj0xOye+JjIiISH7k9PPIiIiIiNyOEhkRERFxWkpkRERExGkpkRERERGnpURGREREnJYSGREREXFaSmRERETEaSmREREREaelREZERESclhIZERERcVpKZERERMRpKZERERERp6VERkRERJyWEhkRERFxWkpkRERExGkpkRERERGnpURGREREnJYSGREREXFaSmRERETEaSmREREREaelREZERESclhIZERERcVpKZERERMRpKZERERERp6VERkRERJyWEhkRERFxWkpk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snz58uyNTkRERCQDWU5kypQpQ1xcHAAVK1bkhx9+AGDr1q14eHhkb3QiIiIiGchyIvPoo4+ydu1aAF588UVGjRpFpUqV6N27N08//XS2BygiIiKSnrueR2bz5s1s3ryZSpUqER4enl1xZRvNIyMiIuJ8cm0emUaNGjFs2LAcS2JOnDhBz549KV68OF5eXtSsWZNt27blyGuJiIiIc7mrRMbX15e//voru2Kxc/78eZo0aUKhQoVYuXIlMTExfPDBB/j7++fYa4qIiIjzyPSEeCdPniQoKMimLBvvbpCm8ePHExwczOzZs61lISEhOfqaIiIi4jwy3SJTvXp15s2bl5Ox2FmyZAn169fn8ccfp2TJktx3333MmjUrw3WuXr1KYmKizUNERETyp0wnMu+88w7PPfccjz/+uPU2BD179szRDrR//fUXkZGRVKpUidWrVzNw4EBeeuklPvvss3TXGTduHH5+ftZHcHBwjsUnIiIijpWlUUuxsbH079+fmJgYZs2aleOjlNzd3alfvz6//vqrteyll15i69atbN68Oc11rl69ytWrV63PExMTCQ4O1qglERERJ5LZUUtZumlkSEgIP/30E9OnT6dLly5Uq1YNNzfbTURHR99ZxGkIDAwkNDTUpqxatWp899136a7j4eGhiflEREQKiCzf/frIkSMsXLgQf39/OnXqZJfIZKcmTZqwf/9+m7IDBw5Qrly5HHtNERERcR5ZykJSbxbZunVr/vjjD0qUKJFTcQHw8ssv07hxY9599126detGVFQUM2fOZObMmTn6uiIiIuIcMt1HJiwsjKioKCZPnkzv3r1zOi6rZcuWMXLkSA4ePEhISAjDhg1jwIABmV5fM/uKiIg4n2zvI2M2m/n9998pU6ZMtgSYWR07dqRjx465+poiIiLiHDKdyKxZsyYn4xARERHJsru+15KIiIiIoyiREREREaelREZERESclhIZERERcVpKZERERMRpKZERERERp6VERkRERJyWEhkRERFxWkpkRERExGkpkRERERGnpURGREREnJYSGREREXFaSmRERETEaWX67tfyL3OKQVRsPGeSkinp40nDkGK4upgcHZaIiEiBo0Qmi1btiWPs0hjiEpKtZYF+nkSEhxJWI9CBkYmIiBQ8urSUBav2xDFwbrRNEgNwKiGZgXOjWbUnzkGRiYiIFExK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HAGk+Zg9e7ajQ8tTCvrwa8MwjKVLlxo1atQwPDw8jKpVqxozZ850dEgOl5iYaAwZMsQoW7as4enpaVSoUMF44403jKtXrzo6tFyzbt26NM8hffr0MQzDMgR71KhRRqlSpQwPDw+jVatWxv79+x0bdC7IaL/Exsame+5dt26do0PPMbc7Vm6Vm8OvTYZRgKaxFBERkXxFnX1FRETEaSmREREREaelREZERESclhIZERERcVpKZERERMRpKZERERERp6VERkRERJyWEhkRKRDWr1+PyWTiwoULjg5FRLKREhkRyVVms5nGjRvTpUsXm/KEhASCg4N54403cuR1GzduTFxcHH5+fjmyfRFxDM3sKyK57sCBA9SpU4dZs2bRo0cPAHr37s2uXbvYunVrvr1fjYhkP7XIiEiuq1y5Mu+99x4vvvgicXFxfP/998yfP5/PP/883SRmxIgRVK5cmcKFC1OhQgVGjRrF9evXAcud4Fu3bk3btm1J/W0WHx9PmTJlGD16NGB/aenIkSOEh4fj7+9PkSJFqF69OitWrMj5Ny8i2Up3vxYRh3jxxRdZtGgRvXr1Yvfu3YwePZratWunW9/Hx4c5c+YQFBTE7t27GTBgAD4+Prz22muYTCY+++wzatasydSpUxkyZAjPP/88pUuXtiYytxo0aBDXrl3jl19+oUiRIsTExODt7Z1Tb1dEcoguLYmIw+zbt49q1apRs2ZNoqOjcXPL/G+riRMnMn/+fLZt22YtW7BgAb1792bo0KFMmzaNHTt2UKlSJcDSItOyZUvOnz9P0aJFqVWrFo899hgRERHZ/r5EJPfo0pKIOMynn35K4cKFiY2N5fjx4wA8//zzeHt7Wx+pvv76a5o0aUJAQADe3t68+eabHD161GZ7jz/+OI8++ijvvfceEydOtCYxaXnppZf4z3/+Q5MmTYiIiOD333/PmTcpIjlKiYyIOMSvv/7Khx9+yLJly2jYsCH9+/fHMAzeeustdu7caX0AbN68mR49etC+fXuWLVvGjh07eOONN7h27ZrNNi9fvsz27dtxdXXl4MGDGb7+M888w19//WW9tFW/fn2mTZuWU29XRHKIEhkRyXWXL1+mb9++DBw4kJYtW/K///2PqKgoZsyYQcmSJbn33nutD7AkPeXKleONN96gfv36VKpUiSNHjthtd/jw4bi4uLBy5UqmTp3KTz/9lGEcwcHBPP/88yxcuJDhw4cza9asHHm/IpJzlMiISK4bOXIkhmHw3nvvAVC+fHkmTpzIa6+9xuHDh+3qV6pUiaNHjzJ//nwOHTrE1KlTWbRokU2d5cuX8+mnn/Lll1/Spk0bXn31Vfr06cP58+fTjGHo0KGsXr2a2NhYoqOjWbduHdWqVcv29yoiOUudfUUkV/3888+0atWK9evX07RpU5tlbdu25caNG/z444+YTCabZa+99hqffvopV69epUOHDjzwwAOMGTOGCxcucPbsWWrWrMmQIUMYOXIkANevX6dRo0ZUrFiRr7/+2q6z74svvsjKlSs5fvw4vr6+hIWF8eGHH1K8ePFc2xcicveUyIiIiIjT0qUlERERcVpKZERERMRpKZERERERp6VERkRERJyWEhkRERFxWkpkRERExGkpkRERERGnpURGREREnJYSGREREXFaSmRERETEaSmREREREaelREZERESc1v8DNtL5yjJdp48AAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# unitary test of nearest function (which is indirectly testing knn function)\n", + "import matplotlib.pyplot as plt\n", + "\n", + "\n", + "x = [point[0] for point in points_array]\n", + "y = [point[1] for point in points_array]\n", + "\n", + "\n", + "first_location = [2, 1]\n", + "closest_to_first_location = nearest(first_location[0], first_location[1])\n", + "\n", + "second_location = [8,8]\n", + "closest_to_second_location= nearest(second_location[0], second_location[1])\n", + "\n", + "third_location = [12,13]\n", + "closest_to_third_location= nearest(third_location[0], third_location[1])\n", + "\n", + "\n", + "plt.scatter(x, y)\n", + "\n", + "\n", + "plt.scatter(first_location[0], first_location[1], color='red', label='First location')\n", + "plt.scatter(second_location[0], second_location[1], color='green', label='Second location')\n", + "plt.scatter(third_location[0], third_location[1], color='yellow', label='Third location')\n", + "\n", + "for point in closest_to_first_location:\n", + " plt.scatter(point[0], point[1], marker='o', facecolor='none', edgecolor='red', s=200)\n", + "\n", + "for point in closest_to_second_location:\n", + " plt.scatter(point[0], point[1], marker='o', facecolor='none', edgecolor='green', s=200)\n", + "\n", + "for point in closest_to_third_location:\n", + " plt.scatter(point[0], point[1], marker='o', facecolor='none', edgecolor='yellow', s=200)\n", + "\n", + "\n", + "\n", + "plt.xlabel('X-axis')\n", + "plt.ylabel('Y-axis')\n", + "plt.title('Input points (colored) and their closest neighbors (circled)')\n", + "plt.legend()\n", + "\n", + "plt.show()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "zama", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.18" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/NearestNeighbors/NearestExample.ipynb b/NearestNeighbors/NearestExample.ipynb deleted file mode 100644 index 69978e3..0000000 --- a/NearestNeighbors/NearestExample.ipynb +++ /dev/null @@ -1,194 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Nearest Example" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Server's Data Setup\n", - "The server owns coordinates to points of interest like restaurants and commerces. The coordinates are kept in a LookupTable" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from concrete import fhe\n", - "import numpy\n", - "\n", - "\n", - "# Database of Points of Interests\n", - "points_array = numpy.array([\n", - " [2, 3], [1, 5], [3, 2], [5, 2], [1, 1],\n", - " [9, 4], [13, 2], [14, 13], [9, 8], [8, 0],\n", - " [2, 10], [3, 8], [8, 12], [4, 10], [7, 7],\n", - "])\n", - "N_PTS = points_array.shape[0]\n", - "points = fhe.LookupTable(points_array.flatten())\n", - "\n", - "\n", - "def get_point(index):\n", - " return (points[2*index], points[2*index + 1])\n", - "\n", - "\n", - "def all_distances(x, y):\n", - " xs = numpy.arange(0, 2 * N_PTS, 2)\n", - " ys = numpy.arange(1, 2 * N_PTS, 2)\n", - " a = abs(points[xs] - x)\n", - " b = abs(points[ys] - y)\n", - " return a + b" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We use swap sort to find the $K$ nearest points to a given point. However, we are interested in the indices of the elements, not just their distances. We must therefore work on tuples of index and distance, effectively implementing numpy argpartition." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "# TLUs\n", - "relu = fhe.univariate(lambda x: x if x > 0 else 0)\n", - "is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0)\n", - "arg_selection = fhe.univariate(lambda x: (x-1)//2 if x % 2 else 0) # relu packed with a flag (alternating between 0 and relu)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "def swap(this_idx, this_dist, that_idx, that_dist):\n", - " \"\"\"\n", - " Swaps this and that if this > that. \n", - " We must pass both the index and the distance for both this and that.\n", - "\n", - " Returns:\n", - " idxmin, min, idxmax, max of this and that based on distance\n", - " \"\"\"\n", - " diff = this_dist - that_dist\n", - " idx = arg_selection(2 * (this_idx - that_idx) + is_positive(diff))\n", - " dist = relu(diff)\n", - "\n", - " idx_min = this_idx - idx\n", - " idx_max = that_idx + idx \n", - " dist_min = this_dist - dist\n", - " dist_max = that_dist + dist\n", - " return fhe.array([idx_min, dist_min, idx_max, dist_max])\n", - "\n", - "\n", - "@fhe.compiler({\"x\": \"encrypted\", \"y\": \"encrypted\"})\n", - "def knn(x, y):\n", - " dist = all_distances(x, y)\n", - " idx = list(range(N_PTS))\n", - " for k in range(2):\n", - " for i in range(k+1, N_PTS):\n", - " idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i])\n", - " return fhe.array([get_point(idx[j]) for j in range(2)])\n", - "\n", - "\n", - "inputset = [(4, 3), (0, 0), (15, 3), (4, 15)]\n", - "\n", - "circuit = knn.compile(inputset)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Client\n", - "The client simply invokes the server's nearest neighbours circuit." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "57.9 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" - ] - } - ], - "source": [ - "%%timeit -r 1 -n 1\n", - "circuit.client.keys.generate()" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "def nearest(x, y):\n", - " ex, ey = circuit.encrypt(x, y)\n", - " res = circuit.run(ex, ey) # Simulate request to the server\n", - " return circuit.decrypt(res)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Benchmarks" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "21.7 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" - ] - } - ], - "source": [ - "%%timeit -r 1 -n 1\n", - "nearest(4, 3)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "zama", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.5" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/NearestNeighbors/README.MD b/NearestNeighbors/README.MD new file mode 100644 index 0000000..6870f36 --- /dev/null +++ b/NearestNeighbors/README.MD @@ -0,0 +1,133 @@ +# K Nearest Neighbors +## Introduction + +The use of maps to find nearest point of interest (e.g. closest restaurants) is common. However, using such features may reveals sensitive personal information. In this tutorial, we demostrate how to use Zama's homomorphic encryption to find the K nearest restaurant to a given input while preserving the localisation's privacy. + +In this scenario, the server does not learn anything about a client's position at anytime as it sees only encrypted data. + +## Achieving Privacy +In our application demo: + + 1. The client owns its position. This information is never shared in clear with the server. + 1. The server owns map data. It only shares with the client the coordinates of the K closest restaurants to its position. + +To achieve this, FHE is used to implement swap sort. The server receives the encrypted coordinates and calculates the distance for each restaurant on the list. The server then perfoms a partial sort using swaps (an equivalent to [bubble sort](https://en.wikipedia.org/wiki/Bubble_sort)). It retrieves the encrypted coordinates of the K nearest restaurants and sends them to the client. The client decrypts the results. + +## Demo App +A demo streamlit app is available on [Quadratic's HuggingFace](https://huggingface.co/spaces/Quadratic-Labs/PrivateNearestNeighbors-FHE): + +It can also be deployed locally in the usual way: + + 1. Create a python virtual environnement and activate it. + 1. Install dependencies from requirements.txt: pip install -r requirements.txt + 1. Launch the app: streamlit run app.py + +The app depends also on files found in data/ folder: + + 1. circuit.zip : use generate_circuit.py to recompile the circuit if necessary (on new network data). + 1. key.zip : cached keys. If not present, they will be generated. + 1. restaurants.geojson: map data extracted from OSM (overpass-turbo API). See NearestExample.ipynb for more information. + +## Data Extraction + +The data used as example in this notebook are extracted from [overpass-turbo](https://overpass-turbo.eu) using the following query : + +``` + // gather results + nwr["amenity"="restaurant"](48.874256063816,2.3389828205109,48.876909238658,2.3433548212051); + + // print results + out; +``` + +This query returns a list of restaurants in a given area + + +## Explanation of the swap sort + + +The swap sort is implemented using three TLUs : +``` + relu = fhe.univariate(lambda x: x if x > 0 else 0) + is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0) + odd_halving = fhe.univariate(lambda x: (x - 1) // 2 if x % 2 else 0) +``` + +The first returns x if x is strictly positive and zero otherwise. +The second returns one if x is positive and zero otherwise. +The last returns half the value minus one for odd numbers, and zero otherwise. + + +The combination of the three functions is used to perform the swap. Here a snippet of the swap function: + +``` +def swap(this_idx, this_dist, that_idx, that_dist): + """ + Swaps this and that if this > that. + We must pass both the index and the distance for both this and that. + + Returns: + idxmin, min, idxmax, max of this and that based on distance + """ + diff = this_dist - that_dist + idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff)) + dist = relu(diff) + + idx_min = this_idx - idx + idx_max = that_idx + idx + dist_min = this_dist - dist + dist_max = that_dist + dist + return fhe.array([idx_min, dist_min, idx_max, dist_max]) +``` + +To decide weither or not to swap two values with their respective indices, we start by calculating the difference. + +``` +diff = this_dist - that_dist +``` + +Then, on the basis of this difference, we calculate 'idx', the index change to be made on the original indices. + + +``` +idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff)) +``` + +If the difference is positive (i.e. this > that), the input to odd_halving is always odd ((this_idx - that_idx) + (this_idx - that_idx) is even). +The result of odd_halving is then (this_idx - that_idx), which is the difference to be applied to the original indices. Otherwise, the result is zero and the indices remain unchanged. + + +The values are swaped (or not) depending on the diff value. We use a relu to achieve this. If the difference is positive, the values should be swaped, otherwise the relu returns zero and the values remain unchanged. + + +The code extract below illustrates how the swap function works. + + +``` +if this > that : + diff = this_dist - that_dist > 0 + idx = odd_halving(2 * (this_idx - that_idx) + is_positive(diff)) = odd_halving(2 * (this_idx - that_idx) + 1) = this_idx - that_idx + dist = relu(diff) = diff + + idx_min = this_idx - idx = this_idx - (this_idx - that_idx) = that_idx + idx_max = that_idx + idx = that_idx + (this_idx - that_idx) = this_idx + dist_min = this_dist - dist = this_dist - diff = this_dist - (this_dist - that_dist) = that_dist + dist_max = that_dist + dist = that_dist + diff = that_dist + (this_dist - that_dist) = this_dist + +else : + diff = this_dist - that_dist < 0 + idx = odd_halving(2 * (this_idx - that_idx) + is_positive(diff)) = odd_halving(2 * (this_idx - that_idx) + 0) = 0 + dist = relu(diff) = 0 + + idx_min = this_idx - idx = this_idx + idx_max = that_idx + idx = that_idx + dist_min = this_dist - dist = this_dist + dist_max = that_dist + dist = that_dist +``` + +## Size of the map +The list of restaurants is presently confined to a predetermined area. We select seven restaurants and adjust the number of neighbors, K, to two (both parameters can be updated in config.py). With the current configuration on the Hugging Face server, the expected running times are up to 15 seconds for key generation and up to 4 minutes for the run. Increasing the number of displayed restaurants or the number of neighbors will significantly increase the execution time. Please refer to the figure below for different settings. + +![image](./figures/exec_time.png) + +One potential optimization is using [heap sort](https://en.wikipedia.org/wiki/Heapsort) instead of swap sort. This change will decrease the number of comparisons and, presumably, reduce the execution time. \ No newline at end of file diff --git a/NearestNeighbors/figures/exec_time.png b/NearestNeighbors/figures/exec_time.png new file mode 100644 index 0000000000000000000000000000000000000000..136f6e53db97ed049e6bfa5a9ca34ff62d80ffe3 GIT binary patch literal 8239 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 literal 0 HcmV?d00001 diff --git a/NearestNeighbors/generate_circuit.py b/NearestNeighbors/generate_circuit.py index 8bd8f25..cedf660 100644 --- a/NearestNeighbors/generate_circuit.py +++ b/NearestNeighbors/generate_circuit.py @@ -26,9 +26,7 @@ def all_distances(x, y): # TLUs relu = fhe.univariate(lambda x: x if x > 0 else 0) is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0) -arg_selection = fhe.univariate( - lambda x: (x - 1) // 2 if x % 2 else 0 -) # relu packed with a flag (alternating between 0 and relu) +odd_halving = fhe.univariate(lambda x: (x - 1) // 2 if x % 2 else 0) def swap(this_idx, this_dist, that_idx, that_dist): @@ -40,7 +38,7 @@ def swap(this_idx, this_dist, that_idx, that_dist): idxmin, min, idxmax, max of this and that based on distance """ diff = this_dist - that_dist - idx = arg_selection(2 * (this_idx - that_idx) + is_positive(diff)) + idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff)) dist = relu(diff) idx_min = this_idx - idx From fd00a634914dd0081e3da317e4a9ec3917939f49 Mon Sep 17 00:00:00 2001 From: Riad15l Date: Wed, 28 Feb 2024 11:26:56 +0100 Subject: [PATCH 8/9] FIX: typos --- NearestNeighbors/README.MD | 14 +++--- NearestNeighbors/generate_circuit.py | 5 +- .../{Getting-Started.ipynb => knn.ipynb} | 46 +++++++++++++------ 3 files changed, 42 insertions(+), 23 deletions(-) rename NearestNeighbors/{Getting-Started.ipynb => knn.ipynb} (96%) diff --git a/NearestNeighbors/README.MD b/NearestNeighbors/README.MD index 6870f36..9080ae4 100644 --- a/NearestNeighbors/README.MD +++ b/NearestNeighbors/README.MD @@ -1,7 +1,7 @@ # K Nearest Neighbors ## Introduction -The use of maps to find nearest point of interest (e.g. closest restaurants) is common. However, using such features may reveals sensitive personal information. In this tutorial, we demostrate how to use Zama's homomorphic encryption to find the K nearest restaurant to a given input while preserving the localisation's privacy. +The use of maps to find nearest point of interest (e.g. closest restaurants) is common. However, using such features may reveals sensitive personal information. In this tutorial, we demonstrate how to use Zama's homomorphic encryption to find the K nearest restaurants to a given input while preserving the localisation's privacy. In this scenario, the server does not learn anything about a client's position at anytime as it sees only encrypted data. @@ -11,7 +11,7 @@ In our application demo: 1. The client owns its position. This information is never shared in clear with the server. 1. The server owns map data. It only shares with the client the coordinates of the K closest restaurants to its position. -To achieve this, FHE is used to implement swap sort. The server receives the encrypted coordinates and calculates the distance for each restaurant on the list. The server then perfoms a partial sort using swaps (an equivalent to [bubble sort](https://en.wikipedia.org/wiki/Bubble_sort)). It retrieves the encrypted coordinates of the K nearest restaurants and sends them to the client. The client decrypts the results. +To achieve this, FHE is used to implement swap sort. The server receives the encrypted coordinates and calculates the distance for each restaurant on the list. The server then performs a partial sort using swaps (an equivalent to [bubble sort](https://en.wikipedia.org/wiki/Bubble_sort)). It retrieves the encrypted coordinates of the K nearest restaurants and sends them to the client. The client decrypts the results. ## Demo App A demo streamlit app is available on [Quadratic's HuggingFace](https://huggingface.co/spaces/Quadratic-Labs/PrivateNearestNeighbors-FHE): @@ -26,7 +26,7 @@ The app depends also on files found in data/ folder: 1. circuit.zip : use generate_circuit.py to recompile the circuit if necessary (on new network data). 1. key.zip : cached keys. If not present, they will be generated. - 1. restaurants.geojson: map data extracted from OSM (overpass-turbo API). See NearestExample.ipynb for more information. + 1. restaurants.geojson: map data extracted from OSM (overpass-turbo API). See knn.ipynb for more information. ## Data Extraction @@ -70,7 +70,7 @@ def swap(this_idx, this_dist, that_idx, that_dist): idxmin, min, idxmax, max of this and that based on distance """ diff = this_dist - that_dist - idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff)) + idx = odd_halving(2 * (this_idx - that_idx) + is_positive(diff)) dist = relu(diff) idx_min = this_idx - idx @@ -90,14 +90,14 @@ Then, on the basis of this difference, we calculate 'idx', the index change to b ``` -idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff)) +idx = odd_halving(2 * (this_idx - that_idx) + is_positive(diff)) ``` -If the difference is positive (i.e. this > that), the input to odd_halving is always odd ((this_idx - that_idx) + (this_idx - that_idx) is even). +If the difference is positive (i.e. this > that), the input to odd_halving is always odd (2 * (this_idx - that_idx) is even). The result of odd_halving is then (this_idx - that_idx), which is the difference to be applied to the original indices. Otherwise, the result is zero and the indices remain unchanged. -The values are swaped (or not) depending on the diff value. We use a relu to achieve this. If the difference is positive, the values should be swaped, otherwise the relu returns zero and the values remain unchanged. +The values are swapped (or not) depending on the diff value. We use a relu to achieve this. If the difference is positive, the values should be swaped, otherwise the relu returns zero and the values remain unchanged. The code extract below illustrates how the swap function works. diff --git a/NearestNeighbors/generate_circuit.py b/NearestNeighbors/generate_circuit.py index cedf660..24de5a9 100644 --- a/NearestNeighbors/generate_circuit.py +++ b/NearestNeighbors/generate_circuit.py @@ -38,7 +38,7 @@ def swap(this_idx, this_dist, that_idx, that_dist): idxmin, min, idxmax, max of this and that based on distance """ diff = this_dist - that_dist - idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff)) + idx = odd_halving(2 * (this_idx - that_idx) + is_positive(diff)) dist = relu(diff) idx_min = this_idx - idx @@ -58,7 +58,8 @@ def knn(x, y): return fhe.array([get_point(idx[j]) for j in range(config.number_of_neighbors)]) -inputset = [(1550, 4289), (1908, 3972), (1705, 4253), (1980, 4071)] +inputset = [(1550, 4289), (1908, 3972), (1705, 4253), (1980, 4071), (1390, 4305), + (1236, 3901), (1469, 4108), (1474, 3842), (1325, 3542), (1643, 4879)] circuit = knn.compile(inputset) diff --git a/NearestNeighbors/Getting-Started.ipynb b/NearestNeighbors/knn.ipynb similarity index 96% rename from NearestNeighbors/Getting-Started.ipynb rename to NearestNeighbors/knn.ipynb index 72a2a94..c90ac3f 100644 --- a/NearestNeighbors/Getting-Started.ipynb +++ b/NearestNeighbors/knn.ipynb @@ -90,7 +90,7 @@ " idxmin, min, idxmax, max of this and that based on distance\n", " \"\"\"\n", " diff = this_dist - that_dist\n", - " idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff))\n", + " idx = odd_halving(2 * (this_idx - that_idx) + is_positive(diff))\n", " dist = relu(diff)\n", "\n", " idx_min = this_idx - idx\n", @@ -110,7 +110,7 @@ " return fhe.array([get_point(idx[j]) for j in range(2)])\n", "\n", "\n", - "inputset = [(4, 3), (0, 0), (15, 3), (4, 15)]\n", + "inputset = [(4, 3), (0, 0), (15, 3), (4, 15), (9, 4), (13, 2), (14, 13), (9, 8), (8, 0), (2, 10), (3, 8), (8, 12), (4, 10), (7, 7)]\n", "\n", "circuit = knn.compile(inputset)\n" ] @@ -132,7 +132,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "5.48 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" + "4.8 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" ] } ], @@ -169,7 +169,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "9.85 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" + "9.08 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)\n" ] } ], @@ -204,13 +204,13 @@ "Suppose we have two values, this = 50 and that = 20, the swap function should swap the values and the indexes:\n", "New index of this is 10\n", "New value of this is 20\n", - "New index of this is 0\n", - "New value of this is 50\n", - "Now if we have , this = 5 and that = 20, the swap function should keep everythin unchanged:\n", + "New index of that is 0\n", + "New value of that is 50\n", + "Now if we have , this = 5 and that = 20, the swap function should keep everything unchanged:\n", "New index of this is 0\n", "New value of this is 5\n", - "New index of this is 10\n", - "New value of this is 20\n" + "New index of that is 10\n", + "New value of that is 20\n" ] } ], @@ -226,12 +226,12 @@ "print(f\"New index of this is {this_idx}\")\n", "print(f\"New value of this is {this_dist}\")\n", "\n", - "print(f\"New index of this is {that_idx}\")\n", - "print(f\"New value of this is {that_dist}\")\n", + "print(f\"New index of that is {that_idx}\")\n", + "print(f\"New value of that is {that_dist}\")\n", "\n", "\n", "\n", - "print(\"Now if we have , this = 5 and that = 20, the swap function should keep everythin unchanged:\")\n", + "print(\"Now if we have , this = 5 and that = 20, the swap function should keep everything unchanged:\")\n", "this_idx = 0\n", "this_dist = 5\n", "that_idx = 10\n", @@ -241,8 +241,8 @@ "print(f\"New index of this is {this_idx}\")\n", "print(f\"New value of this is {this_dist}\")\n", "\n", - "print(f\"New index of this is {that_idx}\")\n", - "print(f\"New value of this is {that_dist}\")" + "print(f\"New index of that is {that_idx}\")\n", + "print(f\"New value of that is {that_dist}\")" ] }, { @@ -305,6 +305,24 @@ "\n", "plt.show()\n" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The two functions knn and nearest achieve the same objective. The only difference is that nearest adds input encryption and output decryption to simulate a client-server communication. The tests below show that their results are similar." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "assert (knn(first_location[0], first_location[1]) == nearest(first_location[0], first_location[1])).all()\n", + "assert (knn(second_location[0], second_location[1]) == nearest(second_location[0], second_location[1])).all()\n", + "assert (knn(third_location[0], third_location[1]) == nearest(third_location[0], third_location[1])).all()" + ] } ], "metadata": { From 10680bd593fb4d78e345dc34de86665267647216 Mon Sep 17 00:00:00 2001 From: Riad15l Date: Wed, 28 Feb 2024 11:27:42 +0100 Subject: [PATCH 9/9] CHORE: add comparison to concrete ML --- NearestNeighbors/comparison/KnnConcrete.ipynb | 208 +++++++++++++++ .../comparison/KnnQuadratic.ipynb | 240 ++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 NearestNeighbors/comparison/KnnConcrete.ipynb create mode 100644 NearestNeighbors/comparison/KnnQuadratic.ipynb diff --git a/NearestNeighbors/comparison/KnnConcrete.ipynb b/NearestNeighbors/comparison/KnnConcrete.ipynb new file mode 100644 index 0000000..59fea13 --- /dev/null +++ b/NearestNeighbors/comparison/KnnConcrete.ipynb @@ -0,0 +1,208 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/riad/envs/zama/lib/python3.9/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + }, + { + "data": { + "text/html": [ + "
KNeighborsClassifier(n_bits=6)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "KNeighborsClassifier(n_bits=6)" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import time\n", + "\n", + "from concrete.ml.sklearn import KNeighborsClassifier as ConcreteKNeighborsClassifier\n", + "import numpy as np\n", + "points_array = np.array([\n", + " [2, 3], [1, 5], [3, 2], [5, 2], [1, 1],\n", + " [9, 4], [13, 2], [14, 13], [9, 8], [8, 0],\n", + " [2, 10], [3, 8], [8, 12], [4, 10], [7, 7],\n", + "])\n", + "N_PTS = points_array.shape[0]\n", + "\n", + "n_neighbors = 3\n", + "concrete_knn = ConcreteKNeighborsClassifier(n_bits=6, n_neighbors=n_neighbors)\n", + "indexes = np.arange(N_PTS)\n", + "concrete_knn.fit(points_array.astype(float), indexes)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Compilation time: 16.67 seconds\n" + ] + } + ], + "source": [ + "time_begin = time.time()\n", + "circuit = concrete_knn.compile(points_array)\n", + "print(f\"Compilation time: {time.time() - time_begin:.2f} seconds\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Maximum bit-width reached in the circuit: 14\n" + ] + } + ], + "source": [ + "print(f\"Maximum bit-width reached in the circuit: {circuit.graph.maximum_integer_bit_width()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Key generation time: 3.44 seconds\n" + ] + } + ], + "source": [ + "time_begin = time.time()\n", + "circuit.client.keygen()\n", + "print(f\"Key generation time: {time.time() - time_begin:.2f} seconds\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "x, y = 4, 3" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "FHE inference execution time:--- 1203.0613384246826 seconds ---\n" + ] + } + ], + "source": [ + "start_time = time.time()\n", + "decrypted_res = concrete_knn.get_topk_labels([(x,y)], fhe=\"execute\")\n", + "print(\"FHE inference execution time:--- %s seconds ---\" % (time.time() - start_time))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "# Gives the same execution time but inacurate results\n", + "# start_time = time.time()\n", + "# pos_enc = circuit.client.encrypt([[x, y]])\n", + "# result = circuit.server.run(pos_enc, evaluation_keys=circuit.client.evaluation_keys)\n", + "# decrypted_res = circuit.client.decrypt(result)\n", + "# print(\"FHE inference execution time:--- %s seconds ---\" % (time.time() - start_time))" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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lLW/33YdZEoCgwwREZH8WPv7bdeHloiIiIgKwyBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREslXkIJOYmIhffvnF+Pzjjz9Go0aN8N///hc3btwo0eKIqGzS6gQOnb+OzSmXcej8dWh1QuqSiEimihxkJk+ejJycHADAyZMn8dprr6Fz585IT0/HpEmTSrxAIipbElPVaDF3D/qt+A3j16Wg34rf0GLuHiSmqqUujYhkqMhBJj09HWFhYQCA7777Di+88ALee+89fPzxx9i2bVuJF0hEZUdiqhqxa5Kh1uSbtGdq8hG7JplhhoiKrMhBxtnZGXl5eQCAXbt2oX379gAAb29v454aIqJHaXUC8VvSYOkgkqEtfksaDzMRUZE4FnWGFi1aYNKkSYiKikJSUhK++eYbAMDZs2cRFBRU4gUSUdmQlJ5ttifmYQKAWpOPpPRsNAv1sV1hRCRrRd4j89FHH8HR0REbNmxAQkICqlatCgDYtm0bOnbsWOIFElHZkJVbcIgpznREREAx9shUq1YNW7duNWtfuHBhiRRERGWTn4driU5HRARYGWRycnLg6elp/P/CGKYjInpYZIg3AlSuyNTkWxwnowDgr3JFZIi3rUsjIhmzKshUqlQJarUafn5+8PLygkKhMJtGCAGFQgGtVlviRRKR/CkdFIiLCUPsmmQoAJMwY9iixMWEQelgvn0hIiqIVUFmz5498Pb2Nv6/pSBDRPQ4HcMDkDCgMeK3pJkM/PVXuSIuJgwdwwMkrI6I5EghhCjT5zrm5ORApVJBo9HwsBeRndDqBJLSs5GVmw8/D/3hJO6JIaKHWfv7XeSzlmbOnAmdTmfWrtFo0K9fv6IujojKIaWDAs1CfdCtUVU0C/VhiCGiYitykPnss8/QokULXLhwwdi2b98+RERE4Pz58yVaHBEREVFhihxkfv/9dwQFBaFRo0ZYsWIFJk+ejPbt22PgwIE4ePBgadRIREREZFGRryNTqVIlfPvtt3jzzTcxcuRIODo6Ytu2bWjXrl1p1EdERERUoCLvkQGApUuXYvHixejXrx9q1qyJcePG4cSJEyVdGxEREVGhihxkOnbsiPj4eKxevRpr167F8ePH8dxzz+E///kP5s2bVxo1EhEREVlU5CCj1Wrx+++/o2fPngAANzc3JCQkYMOGDbxNAREREdlUiV5H5p9//kHlypVLanElgteRISIikp9Su45MYewtxBAREVHZVuSzlrRaLRYuXIhvv/0Wf/31F+7du2fyenZ2dokVR0RERFSYIu+RiY+Px4cffog+ffpAo9Fg0qRJ6NGjBxwcHDBz5sxSKJGIiIjIsiIHmbVr12LFihV47bXX4OjoiH79+uF///sfZsyYgd9++600aiQiIiKyqMhBJjMzExEREQCAihUrQqPRAABeeOEF/PjjjyVbHREREVEhihxkgoKCoFarAQChoaHYsWMHAODIkSNwcXEp2eqIiIiIClHkIPPiiy9i9+7dAICxY8di+vTpqF27NgYNGoRhw4aVeIFEREREBXni68gcOnQIhw4dQu3atRETE1NSdZUYXkeGiIhIfmx2HZlmzZph0qRJpRZiLl++jAEDBsDHxwdubm6IiIjA0aNHS+W9iIiISF6eKMh4enriwoULJVWLmRs3biAqKgpOTk7Ytm0b0tLS8MEHH6BSpUql9p5EREQkH1ZfEO/KlSsIDAw0aSvBuxtYNHfuXAQHB2PlypXGtpCQkFJ9TyIiIpIPq/fI1K9fH1999VVp1mLmhx9+QNOmTdGrVy/4+fnh6aefxooVKwqd5+7du8jJyTF5EBERUdlkdZCZPXs2Ro4ciV69ehlvQzBgwIBSHUB74cIFJCQkoHbt2ti+fTtiY2Mxbtw4rF69usB55syZA5VKZXwEBweXWn1EREQkrSKdtZSeno7hw4cjLS0NK1asKPWzlJydndG0aVMcPHjQ2DZu3DgcOXIEhw4dsjjP3bt3cffuXePznJwcBAcH86wlIiIiGbH2rKUi3TQyJCQEe/bswUcffYQePXqgXr16cHQ0XURycnLxKrYgICAAYWFhJm316tXDd999V+A8Li4uvDAfERFROVHku19nZGRg48aNqFSpErp162YWZEpSVFQUzpw5Y9J29uxZVK9evdTek4iIiOSjSCnEcLPI6Oho/PHHH/D19S2tugAAEydORPPmzfHee++hd+/eSEpKwvLly7F8+fJSfV8iIiKSB6vHyHTs2BFJSUlYtGgRBg0aVNp1GW3duhXTpk3DuXPnEBISgkmTJuGVV16xen5e2ZeIiEh+SnyMjFarxe+//46goKASKdBaL7zwAl544QWbvicRERHJg9VBZufOnaVZBxEREVGRPfG9loiIiIikwiBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyZfXdr+lfWp1AUno2snLz4efhisgQbygdFFKXRUREVO4wyBRRYqoa8VvSoNbkG9sCVK6IiwlDx/AACSsjIiIqf3hoqQgSU9WIXZNsEmIAIFOTj9g1yUhMVUtUGRERUfnEIGMlrU4gfksahIXXDG3xW9Kg1VmagoiIiEoDg4yVktKzzfbEPEwAUGvykZSebbuiiIiIyjkGGStl5RYcYoozHRERET05Bhkr+Xm4luh0RERE9OQYZKwUGeKNAJUrCjrJWgH92UuRId62LIuIiKhcY5CxktJBgbiYMAAwCzOG53ExYbyeDBERkQ0xyBRBx/AAJAxoDH+V6eEjf5UrEgY05nVkiIiIbIwXxCuijuEBeD7Mn1f2JSIisgMMMsWgdFCgWaiP1GUQERGVezy0RERERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyJasg8/7770OhUGDChAlSl0JEVKK0OoFD569jc8plHDp/HVqdkLokIllwlLoAax05cgSffvopGjRoIHUpREQlKjFVjfgtaVBr8o1tASpXxMWEoWN4gISVEdk/WeyRuXXrFvr3748VK1agUqVKUpdDRFRiElPViF2TbBJiACBTk4/YNclITFVLVBmRPMgiyIwePRpdunRBdHS01KUQEZUYrU4gfksaLB1EMrTFb0njYSaiQtj9oaV169YhOTkZR44csWr6u3fv4u7du8bnOTk5pVUaEdETSUrPNtsT8zABQK3JR1J6NpqF+tiuMCIZses9MpcuXcL48eOxdu1auLq6WjXPnDlzoFKpjI/g4OBSrpKIqHiycgsOMcWZjqg8susgc+zYMWRlZaFx48ZwdHSEo6Mj9u/fjyVLlsDR0RFardZsnmnTpkGj0Rgfly5dkqByIqLH8/Ow7g80a6cjKo/s+tBSu3btcPLkSZO2oUOHom7dupgy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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "\n", + "xs = [point[0] for point in points_array]\n", + "ys = [point[1] for point in points_array]\n", + "\n", + "\n", + "plt.scatter(xs, ys)\n", + "\n", + "\n", + "plt.scatter(x, y, color='red', label='Input')\n", + "\n", + "for point in decrypted_res[0]:\n", + " plt.scatter(xs[point], ys[point], marker='o', facecolor='none', edgecolor='red', s=200)\n", + "\n", + "\n", + "plt.xlabel('X-axis')\n", + "plt.ylabel('Y-axis')\n", + "plt.title('Input point (colored) and its closest neighbors (circled)')\n", + "plt.legend()\n", + "\n", + "plt.show()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "zama", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.18" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/NearestNeighbors/comparison/KnnQuadratic.ipynb b/NearestNeighbors/comparison/KnnQuadratic.ipynb new file mode 100644 index 0000000..34c3b84 --- /dev/null +++ b/NearestNeighbors/comparison/KnnQuadratic.ipynb @@ -0,0 +1,240 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/riad/envs/zama/lib/python3.9/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "from concrete import fhe\n", + "import time\n", + "import numpy as np\n", + "points_array = np.array([\n", + " [2, 3], [1, 5], [3, 2], [5, 2], [1, 1],\n", + " [9, 4], [13, 2], [14, 13], [9, 8], [8, 0],\n", + " [2, 10], [3, 8], [8, 12], [4, 10], [7, 7],\n", + "])\n", + "N_PTS = points_array.shape[0]\n", + "points = fhe.LookupTable(points_array.flatten())\n", + "n_neighbors = 3\n", + "\n", + "def get_point(index):\n", + " return (points[2*index], points[2*index + 1])\n", + "\n", + "\n", + "def all_distances(x, y):\n", + " xs = np.arange(0, 2 * N_PTS, 2)\n", + " ys = np.arange(1, 2 * N_PTS, 2)\n", + " a = abs(points[xs] - x)\n", + " b = abs(points[ys] - y)\n", + " return a + b\n", + "# TLUs\n", + "relu = fhe.univariate(lambda x: x if x > 0 else 0)\n", + "is_positive = fhe.univariate(lambda x: 1 if x > 0 else 0)\n", + "odd_halving = fhe.univariate(lambda x: (x-1)//2 if x % 2 else 0) \n", + "\n", + "def swap(this_idx, this_dist, that_idx, that_dist):\n", + " \"\"\"\n", + " Swaps this and that if this > that. \n", + " We must pass both the index and the distance for both this and that.\n", + "\n", + " Returns:\n", + " idxmin, min, idxmax, max of this and that based on distance\n", + " \"\"\"\n", + " diff = this_dist - that_dist\n", + " idx = odd_halving((this_idx - that_idx) + (this_idx - that_idx) + is_positive(diff))\n", + " dist = relu(diff)\n", + "\n", + " idx_min = this_idx - idx\n", + " idx_max = that_idx + idx \n", + " dist_min = this_dist - dist\n", + " dist_max = that_dist + dist\n", + " return fhe.array([idx_min, dist_min, idx_max, dist_max])\n", + "\n", + "\n", + "@fhe.compiler({\"x\": \"encrypted\", \"y\": \"encrypted\"})\n", + "def knn(x, y):\n", + " dist = all_distances(x, y)\n", + " idx = list(range(N_PTS))\n", + " for k in range(n_neighbors):\n", + " for i in range(k+1, N_PTS):\n", + " idx[k], dist[k], idx[i], dist[i] = swap(idx[k], dist[k], idx[i], dist[i])\n", + " return fhe.array([get_point(idx[j]) for j in range(n_neighbors)])\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Compilation time: 7.41 seconds\n" + ] + } + ], + "source": [ + "inputset = [(4, 3), (0, 0), (15, 3), (4, 15), (9, 4), (13, 2), (14, 13), (9, 8), (8, 0), (2, 10), (3, 8), (8, 12), (4, 10), (7, 7)]\n", + "time_begin = time.time()\n", + "circuit = knn.compile(inputset)\n", + "print(f\"Compilation time: {time.time() - time_begin:.2f} seconds\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Maximum bit-width reached in the circuit: 6\n" + ] + } + ], + "source": [ + "print(f\"Maximum bit-width reached in the circuit: {circuit.graph.maximum_integer_bit_width()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Key generation time: 8.71 seconds\n" + ] + } + ], + "source": [ + "time_begin = time.time()\n", + "circuit.client.keygen()\n", + "print(f\"Key generation time: {time.time() - time_begin:.2f} seconds\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "x, y = 4, 3" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "ex, ey = circuit.encrypt(x, y)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "--- 17.16662859916687 seconds ---\n" + ] + } + ], + "source": [ + "start_time = time.time()\n", + "result = circuit.server.run(ex, ey, evaluation_keys=circuit.client.evaluation_keys)\n", + "print(\"--- %s seconds ---\" % (time.time() - start_time))" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "decrypted_res = circuit.decrypt(result)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjIAAAHHCAYAAACle7JuAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAABYbklEQVR4nO3de3gMZ/8G8HuzOYpkJZFIIkFElJBSNH1J1LEEDaqOrzMtUmd9FW2JVFUdWsc2ytuiL0qpQylxLi3aIKg0ziKURFTYOCXYfX5/7G+31m6STSQ7O8n9ua692n12Zva7j9nZOzPPzCiEEAJEREREMmQndQFERERERcUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDxWbAgAGoVq2a1d5Pq9Wibt26mD59eoks//Lly1AoFFi+fHmJLL+omjdvjubNmxuep6SkwN7eHsnJydIVlYdq1aphwIABBU6nUCgwderUEq+nIM/2LeXtefqqefPmqFu3boHT/fzzz1AoFFi/fn2R3sfa2rdvj7fffrtQ85TEdsbS752lzNU4ceJEvPLKK8X2Hs+DQaaIli9fDoVCgaNHj0pdCgDgwYMHmDp1Kn7++WepSymSbdu2FfqH7LvvvsPVq1cxYsSIkilKJkJDQ9GhQwdMmTJF6lKKzaFDhzB16lTcuXNH6lJsUkpKCqZOnYrLly9LXQr9v4MHD2Lnzp2YMGGC1KVYxZgxY3Dy5En8+OOPUpfCIFNaPHjwAHFxcZIGmaVLl+Ls2bNFmnfbtm2Ii4sr1DyzZ89Gz549oVKpivSepcmwYcOwceNGXLx4UepSiuThw4f48MMPDc8PHTqEuLg4Bpk8pKSkIC4uTtIgs3PnTuzcuVOy97c1s2fPRqtWrVCjRo1CzVe1alU8fPgQffv2LaHKSoavry86deqEOXPmSF0KgwwVHwcHBzg5OVnlvY4fP46TJ0+ie/fuVnm/4nD//v0SW3br1q3h4eGBFStWlNh7lCRnZ2fY29tLXQYVgqOjIxwdHaUuo1g873czMzMTP/30U5G2RwqFAs7OzlAqlflOV5Lbj6Lq3r07fv31V1y6dEnSOhhkitGAAQNQvnx5XLt2DZ07d0b58uXh7e2N//znP9BoNIbp9Mcb58yZg7lz56Jq1apwcXFBs2bNTMY55HUc+unxKJcvX4a3tzcAIC4uDgqFosAxB/pDYwcOHMDQoUPh5eUFd3d39OvXD7dv3zaZ/ssvv0SdOnXg5OQEf39/DB8+3OSv5WfHyDz9OZcsWYLg4GA4OTnh5ZdfxpEjR4zm++KLLwDAULtCocizdgDYtGkTHB0d8eqrr5q8du3aNQwePBj+/v5wcnJCUFAQYmJi8OjRI8M0ly5dQrdu3eDp6Yly5crhX//6F3766ad831Nv7969aNq0KVxdXVGhQgV06tQJp0+fNppm6tSpUCgUSElJwb///W94eHggMjLS8PrKlSvRsGFDuLi4wNPTEz179sTVq1dN3kvfby4uLggPD8cvv/xitiYHBwc0b94cmzdvLrD+tLQ0vPPOO3jhhRfg4uICLy8vdOvWzeSve/06cvDgQYwbNw7e3t5wdXXFG2+8gZs3bxpNK4TAxx9/jICAAJQrVw4tWrTAn3/+WWAtek+vr1OnTsX48eMBAEFBQYb1QV/frl27EBkZiQoVKqB8+fJ44YUX8P7771v0PitXrkR4eDjKlSsHDw8PvPrqqwXuVcjMzMTgwYNRqVIlODs7o169emYD45o1a9CwYUO4ubnB3d0dYWFhmD9/vtE0d+7cwZgxYxAYGAgnJyfUqFEDM2fOhFartXhZy5cvR7du3QAALVq0MPRPfntjLd02AbqxZ/PmzUOdOnXg7OyMSpUqYejQoSbbBXPbprS0NHTs2BGurq7w8fHB2LFjsWPHjjzrS0lJQYsWLVCuXDlUrlwZs2bNMlu/RqPB+++/D19fX7i6uqJjx45mvy/r1q0zfK8qVqyIPn364Nq1a2b74uLFi2jfvj3c3NzQu3dvAMD58+fx5ptvwtfXF87OzggICEDPnj2hVqvz7FsA+Omnn/DkyRO0bt3a5LU7d+5g7NixqFatGpycnBAQEIB+/frh77//BmB+/El+NWq1WsyfPx9hYWFwdnaGt7c3oqKiChzmYOm6d+fOHQwYMAAqlQoVKlRA//7989wzqv+8lmx3ShL/BCpmGo0Gbdu2xSuvvII5c+Zg9+7d+OyzzxAcHIyYmBijab/99lvcvXsXw4cPR05ODubPn4+WLVvi1KlTqFSpksXv6e3tjfj4eMTExOCNN95Aly5dAAAvvvhigfOOGDECFSpUwNSpU3H27FnEx8cjLS3NMMgO0P2wxMXFoXXr1oiJiTFMd+TIERw8eBAODg75vsfq1atx9+5dDB06FAqFArNmzUKXLl1w6dIlODg4YOjQobh+/Tp27dqF//3vfxZ95kOHDqFu3bom7339+nWEh4fjzp07GDJkCGrVqoVr165h/fr1ePDgARwdHXHjxg00adIEDx48wKhRo+Dl5YUVK1agY8eOWL9+Pd54440833f37t1o164dqlevjqlTp+Lhw4dYuHAhIiIikJSUZDLYuVu3bggJCcEnn3wCIQQAYPr06Zg8eTK6d++Ot956Czdv3sTChQvx6quv4vjx46hQoQIA4Ouvv8bQoUPRpEkTjBkzBpcuXULHjh3h6emJwMBAk9oaNmyIzZs3Izs7G+7u7nl+hiNHjuDQoUPo2bMnAgICcPnyZcTHx6N58+ZISUlBuXLljKYfOXIkPDw8EBsbi8uXL2PevHkYMWIE1q5da5hmypQp+Pjjj9G+fXu0b98eSUlJaNOmjVF4tFSXLl1w7tw5fPfdd5g7dy4qVqwIQLee//nnn3j99dfx4osv4qOPPoKTkxMuXLiAgwcPFrjcuLg4TJ06FU2aNMFHH30ER0dH/P7779i7dy/atGljdp6HDx+iefPmuHDhAkaMGIGgoCCsW7cOAwYMwJ07dzB69GgAunDVq1cvtGrVCjNnzgQAnD59GgcPHjRM8+DBAzRr1gzXrl3D0KFDUaVKFRw6dAiTJk1Ceno65s2bZ9GyXn31VYwaNQoLFizA+++/j9q1awOA4b95sXTbNHToUCxfvhwDBw7EqFGjkJqaikWLFuH48eP5ft/v37+Pli1bIj09HaNHj4avry9Wr16Nffv2mZ3+9u3biIqKQpcuXdC9e3esX78eEyZMQFhYGNq1a2c07fTp06FQKDBhwgRkZmZi3rx5aN26NU6cOAEXFxcAMNT88ssvY8aMGbhx4wbmz5+PgwcPGn2vAODJkydo27YtIiMjMWfOHJQrVw6PHj1C27ZtkZubi5EjR8LX1xfXrl3D1q1bcefOnXwPYR86dAheXl6oWrWqUfu9e/fQtGlTnD59GoMGDUKDBg3w999/48cff8Rff/1lWLfNMVcjAAwePBjLly9Hu3bt8NZbb+HJkyf45Zdf8Ntvv6FRo0Zml2XpuieEQKdOnfDrr79i2LBhqF27NjZu3Ij+/fubXa5KpUJwcDAOHjyIsWPH5vlZSpygIlm2bJkAII4cOWJo69+/vwAgPvroI6NpX3rpJdGwYUPD89TUVAFAuLi4iL/++svQ/vvvvwsAYuzYsYa2Zs2aiWbNmpm8f//+/UXVqlUNz2/evCkAiNjY2ELV37BhQ/Ho0SND+6xZswQAsXnzZiGEEJmZmcLR0VG0adNGaDQaw3SLFi0SAMQ333yTZ036z+nl5SWysrIM7Zs3bxYAxJYtWwxtw4cPF4VZHQMCAsSbb75p0t6vXz9hZ2dn9O+ip9VqhRBCjBkzRgAQv/zyi+G1u3fviqCgIFGtWjXD59TXv2zZMsN09evXFz4+PuLWrVuGtpMnTwo7OzvRr18/Q1tsbKwAIHr16mVUw+XLl4VSqRTTp083aj916pSwt7c3tD969Ej4+PiI+vXri9zcXMN0S5YsEQDMrhOrV68WAMTvv/9u8trTHjx4YNJ2+PBhAUB8++23hjb9OtK6dWtD3wkhxNixY4VSqRR37twRQvyzjnTo0MFouvfff18AEP3798+3HiGEybo7e/ZsAUCkpqYaTTd37lwBQNy8ebPAZT7t/Pnzws7OTrzxxhtG67EQwqjmZ79v8+bNEwDEypUrDW2PHj0SjRs3FuXLlxfZ2dlCCCFGjx4t3N3dxZMnT/KsYdq0acLV1VWcO3fOqH3ixIlCqVSKK1euWLysdevWCQBi3759BX52ISzfNv3yyy8CgFi1apXRdAkJCSbtz/bVZ599JgCITZs2GdoePnwoatWqZVJrs2bNTNa33Nxc4evra/S93rdvnwAgKleubOhrIYT4/vvvBQAxf/58IcQ/35e6deuKhw8fGqbbunWrACCmTJli0hcTJ040+ozHjx8XAMS6devMd2I+IiMjjfpRb8qUKQKA2LBhg8lr+vXO3HYmrxr37t0rAIhRo0bluTwhhKhatarR987SdW/Tpk0CgJg1a5ZhmidPnoimTZua1KjXpk0bUbt2bZN2a+KhpRIwbNgwo+dNmzY1ewyxc+fOqFy5suF5eHg4XnnlFWzbtq3Ea9QbMmSI0V9YMTExsLe3N9Swe/duPHr0CGPGjIGd3T+ry9tvvw13d3eLDsf06NEDHh4ehudNmzYFgOc6rnrr1i2jZQK6Xa6bNm1CdHS02b9M9HuYtm3bhvDwcKNDPeXLl8eQIUNw+fJlpKSkmH3P9PR0nDhxAgMGDICnp6eh/cUXX8Rrr71m9t/t2XVhw4YN0Gq16N69O/7++2/Dw9fXFyEhIYa/Xo8ePYrMzEwMGzbMaByCfpevOfr+0O+yzov+L1gAePz4MW7duoUaNWqgQoUKSEpKMpl+yJAhRof6mjZtCo1Gg7S0NAD/rCMjR440mm7MmDH51lEU+r+qN2/ebLJLPD+bNm2CVqvFlClTjNZjAPkexty2bRt8fX3Rq1cvQ5uDgwNGjRqFe/fuYf/+/Ya67t+/j127duW5rHXr1qFp06bw8PAw+rdv3bo1NBoNDhw4YPGyiqqgbdO6deugUqnw2muvGdXYsGFDlC9fPs+9KwCQkJCAypUro2PHjoY2Z2fnPE9HLl++PPr06WN47ujoiPDwcLPbhX79+sHNzc3wvGvXrvDz8zN85/Tfl3feeQfOzs6G6Tp06IBatWqZ3U49u4dc/73asWMHHjx4kOfnNMfc9ggAfvjhB9SrV8/sXt6CDp+bq/GHH36AQqFAbGxsoZZn6bq3bds22NvbG72vUqnEyJEj81y2fplSYpApZvpjlk/z8PAwO+4kJCTEpK1mzZpWPRPh2RrKly8PPz8/Qw36H6sXXnjBaDpHR0dUr17d8Hp+qlSpYvRc/4U31yeFIf7/UI3ezZs3kZ2dXeD1KdLS0kw+D/DPrvm8PlNefaGf9++//zYZkBcUFGT0/Pz58xBCICQkBN7e3kaP06dPIzMz0+i9nv33cXBwQPXq1c3Wp++PgjaQDx8+xJQpUwzHyitWrAhvb2/cuXPH7FiAgv798qrV29vb7Mb9efTo0QMRERF46623UKlSJfTs2RPff/99gaHm4sWLsLOzQ2hoaKHeLy0tDSEhISbh59l15Z133kHNmjXRrl07BAQEYNCgQUhISDCa5/z580hISDD5d9ePM9D/21uyrKKwZNt0/vx5qNVq+Pj4mNR57949Q43mpKWlITg42GT9y+ssnoCAAJNpLd1WKhQK1KhRo8DtFADUqlXL5Dttb2+PgIAAo7agoCCMGzcO//3vf1GxYkW0bdsWX3zxRYHjY/Se3R4BuvXOkuvlmGOuxosXL8Lf39/oDylLWLrupaWlwc/PD+XLlzea31y/6gkhLAplJYljZIpZQSPPC0uhUJj9gjw7QM+W5dUn5j6Xpby8vJ47CFnD03s/AN1eI4VCge3bt5vtl2c3IIWh74/8jrsDujEvy5Ytw5gxY9C4cWOoVCooFAr07NnTbCAoiX+/onJxccGBAwewb98+/PTTT0hISMDatWvRsmVL7Ny5s9i/f5by8fHBiRMnsGPHDmzfvh3bt2/HsmXL0K9fP8PAYK1Wi9deew3vvfee2WXUrFnT4mUVhSV9o9Vq4ePjg1WrVpl9/dkg9DykXK+cnJxMwikAfPbZZxgwYAA2b96MnTt3YtSoUZgxYwZ+++03k1DxtJLYHuVVY1FYuu4Vxe3btwvc5pQ0BhkJnT9/3qTt3LlzRgNGPTw8zO5qffYvjKIm4vPnz6NFixaG5/fu3UN6ejrat28PAIbBa2fPnjXaE/Do0SOkpqaaHaVfFIWtv1atWkhNTTVq8/b2hru7e4FXuK1atarZ692cOXPG8Hpe8wHIc96KFSvC1dU13/cODg6GEAJBQUH5bjz073X+/Hm0bNnS0P748WOkpqaiXr16JvOkpqbCzs6uwI3S+vXr0b9/f3z22WeGtpycnCJfs+XpWp9eR27evFnkjXt+64OdnR1atWqFVq1a4fPPP8cnn3yCDz74APv27ctzfQwODoZWq0VKSgrq169vcR1Vq1bFH3/8Aa1Wa/SjYm5dcXR0RHR0NKKjo6HVavHOO+/gq6++wuTJk1GjRg0EBwfj3r17Fn1nClpWSf0FHBwcjN27dyMiIsIkhBekatWqSElJMfkL/cKFC89d17PbSiEELly4YDih4env5tPfF31bXt9pc8LCwhAWFoYPP/wQhw4dQkREBBYvXoyPP/44z3lq1aqFH374waQ9ODi4WK+4HRwcjB07diArK6tQe2UsXfeqVq2KPXv24N69e0Z/VOV3fbC8tkfWxENLEtq0aZPRqYGJiYn4/fffjUbsBwcH48yZM0anu548edLkLA39iPbC/hgtWbIEjx8/NjyPj4/HkydPDDW0bt0ajo6OWLBggdFfSl9//TXUajU6dOhQqPfLiz4AWFp/48aNkZycjNzcXEObnZ0dOnfujC1btpg9FVFff/v27ZGYmIjDhw8bXrt//z6WLFmCatWq5Xn4wc/PD/Xr18eKFSuM6kxOTsbOnTsN4S8/Xbp0gVKpRFxcnMlfnkII3Lp1CwDQqFEjeHt7Y/HixUZn/ixfvjzPPjp27Bjq1KlT4AUClUqlyXsvXLiwyHv5WrduDQcHByxcuNBoufozIYoir/UhKyvLZFp9MHl6XXhW586dYWdnh48++shkr1N+ewDat2+PjIwMozO0njx5goULF6J8+fJo1qwZABj+3fTs7OwMP7L6urp3747Dhw9jx44dJu9z584dPHnyxOJlFfb7Yqnu3btDo9Fg2rRpJq89efIk3/dr27Ytrl27ZnSl15ycHCxduvS569Kf4am3fv16pKenG7ZTjRo1go+PDxYvXmy0Hmzfvh2nT5+2aDuVnZ1t+DfQCwsLg52dXb7rFqDbHt2+fdvkj84333wTJ0+exMaNG03mKcqepzfffBNCCLMXD81veZaue+3bt8eTJ08QHx9veF2j0WDhwoVml6tWq3Hx4kU0adKksB+lWHGPjIRq1KiByMhIxMTEIDc3F/PmzYOXl5fR7r9Bgwbh888/R9u2bTF48GBkZmZi8eLFqFOnDrKzsw3Tubi4IDQ0FGvXrkXNmjXh6emJunXrFnh89tGjR2jVqhW6d++Os2fP4ssvv0RkZKRhwJ63tzcmTZqEuLg4REVFoWPHjobpXn75ZaPBes+jYcOGAIBRo0ahbdu2UCqV6NmzZ57Td+rUCdOmTcP+/fuNTp395JNPsHPnTjRr1gxDhgxB7dq1kZ6ejnXr1uHXX39FhQoVMHHiRHz33Xdo164dRo0aBU9PT6xYsQKpqan44Ycf8t2dO3v2bLRr1w6NGzfG4MGDDadfq1Qqi26xEBwcjI8//hiTJk3C5cuX0blzZ7i5uSE1NRUbN27EkCFD8J///AcODg74+OOPMXToULRs2RI9evRAamoqli1bZnaMzOPHj7F//3688847Bdbw+uuv43//+x9UKhVCQ0Nx+PBh7N69G15eXgXOa47+eiQzZszA66+/jvbt2+P48ePYvn17kXc569eHDz74AD179oSDgwOio6Px0Ucf4cCBA+jQoQOqVq2KzMxMfPnllwgICDAavP2sGjVq4IMPPsC0adPQtGlTdOnSBU5OTjhy5Aj8/f0xY8YMs/MNGTIEX331FQYMGIBjx46hWrVqWL9+PQ4ePIh58+YZBqC+9dZbyMrKQsuWLREQEIC0tDQsXLgQ9evXN4ynGT9+PH788Ue8/vrrGDBgABo2bIj79+/j1KlTWL9+PS5fvoyKFStatKz69etDqVRi5syZUKvVcHJyQsuWLeHj41Ok/tZr1qwZhg4dihkzZuDEiRNo06YNHBwccP78eaxbtw7z589H165dzc47dOhQLFq0CL169cLo0aPh5+eHVatWGQbfPs9eJE9PT0RGRmLgwIG4ceMG5s2bhxo1ahgGEjs4OGDmzJkYOHAgmjVrhl69ehlOv65WrZpFpwbv3bsXI0aMQLdu3VCzZk08efIE//vf/6BUKvHmm2/mO2+HDh1gb2+P3bt3Y8iQIYb28ePHY/369ejWrRsGDRqEhg0bIisrCz/++CMWL15c6D0ZLVq0QN++fbFgwQKcP38eUVFR0Gq1+OWXX9CiRYs8b9di6boXHR2NiIgITJw4EZcvX0ZoaCg2bNiQ5zih3bt3G07ZlpQVz5AqVfI6/drV1dVkWv2puHr60+1mz54tPvvsMxEYGCicnJxE06ZNxcmTJ03mX7lypahevbpwdHQU9evXFzt27DA51VkIIQ4dOiQaNmwoHB0dCzwVW1///v37xZAhQ4SHh4coX7686N27t9GpxXqLFi0StWrVEg4ODqJSpUoiJiZG3L5922iavE6/nj17tsnynq3vyZMnYuTIkcLb21soFAqLTsV+8cUXxeDBg03a09LSRL9+/YS3t7dwcnIS1atXF8OHDzc6jfnixYuia9euokKFCsLZ2VmEh4eLrVu3Gi3H3GmRQgixe/duERERIVxcXIS7u7uIjo4WKSkpRtPo/83zOk34hx9+EJGRkcLV1VW4urqKWrVqieHDh4uzZ88aTffll1+KoKAg4eTkJBo1aiQOHDhg9pT87du3CwDi/PnzBXWbuH37thg4cKCoWLGiKF++vGjbtq04c+aMySmb5tZxIf45Jfbp02k1Go2Ii4sTfn5+wsXFRTRv3lwkJyebLDMv5tbXadOmicqVKws7OzvDqdh79uwRnTp1Ev7+/sLR0VH4+/uLXr16mZxWmpdvvvlGvPTSS8LJyUl4eHiIZs2aiV27dhleN9e3N27cMPSXo6OjCAsLM1kn1q9fL9q0aSN8fHyEo6OjqFKlihg6dKhIT083mu7u3bti0qRJokaNGsLR0VFUrFhRNGnSRMyZM8dwGQRLl7V06VJRvXp1oVQqCzwV29Jtk96SJUtEw4YNhYuLi3BzcxNhYWHivffeE9evX8+3ry5duiQ6dOggXFxchLe3t3j33XfFDz/8IACI3377zWjeOnXqmK3z6W2Ifl377rvvxKRJk4SPj49wcXERHTp0EGlpaSbzr1271vDv6+npKXr37m10iYv8+uLSpUti0KBBIjg4WDg7OwtPT0/RokULsXv3bpNpzenYsaNo1aqVSfutW7fEiBEjROXKlYWjo6MICAgQ/fv3F3///bcQIu/Tr83VKIRuWzl79mxRq1Yt4ejoKLy9vUW7du3EsWPHDNOY+95Zsu7p6+3bt69wd3cXKpVK9O3b13Bq+rPrfY8ePURkZKRF/VOSGGQkkN8PvLXk9SMlJ99++61wc3MzCVRlUadOnUTnzp2lLoPIhP7aP88GitLmwIEDws7OzuJQLXfp6enC2dnZ6LpBUuEYGZKt3r17o0qVKobbG5RVp0+fxtatW82OayCypocPHxo9z8nJwVdffYWQkBCja2aVRk2bNkWbNm3yvM1CaTNv3jyEhYVJf1gJHCNDMmZnZ1esZwTIVe3atU0GKRJJoUuXLqhSpQrq168PtVqNlStX4syZM3mezl3abN++XeoSrObTTz+VugQDBhkiIioWbdu2xX//+1+sWrUKGo0GoaGhWLNmDXr06CF1aVSKKYSQ4KpWRERERMWAY2SIiIhIthhkiIiISLZK/RgZrVaL69evw83NTfIbWxEREZFlhBC4e/cu/P39871QaakPMtevX0dgYKDUZRAREVERXL16Nd+bdpb6IKO/hPjVq1fh7u4ucTVERERkiezsbAQGBhp+x/NS6oOM/nCSu7s7gwwREZHMFDQshIN9iYiISLYYZIiIiEi2GGSIiIhItkr9GBlLaTQaPH78WOoyyAY5ODhAqVRKXQYREZlR5oOMEAIZGRm4c+eO1KWQDatQoQJ8fX15LSIiIhtT5oOMPsT4+PigXLly/KEiI0IIPHjwAJmZmQAAPz8/iSsiIqKnlekgo9FoDCHGy8tL6nLIRrm4uAAAMjMz4ePjw8NMREQ2pEwP9tWPiSlXrpzElZCt068jHEdFRGRbynSQ0ePhJCoI1xEiIttUpg8tERERUdFotAKJqVnIvJsDHzdnhAd5Qmln/T/6GGRKmebNm6N+/fqYN29eib7P5cuXERQUhOPHj6N+/fol+l4FUSgU2LhxIzp37ixpHUREZUVCcjritqQgXZ1jaPNTOSM2OhRRda17UgQPLcnQgAEDoFAoTB4XLlzAhg0bMG3atOdavkKhwKZNm4qn2GI0depUs6EpPT0d7dq1s35BRERlUEJyOmJWJhmFGADIUOcgZmUSEpLTrVoP98gUB40G+OUXID0d8PMDmjYFSvjMlqioKCxbtsyozdvbu8Azah49egRHR8eSLM3qfH19pS6BiKhM0GgF4rakQJh5TQBQAIjbkoLXQn2tdpiJe2Se14YNQLVqQIsWwL//rftvtWq69hLk5OQEX19fo4dSqUTz5s0xZswYw3TVqlXDtGnT0K9fP7i7u2PIkCF49OgRRowYAT8/Pzg7O6Nq1aqYMWOGYXoAeOONN6BQKAzPLbF//36Eh4fDyckJfn5+mDhxIp48eWJ4XavVYtasWahRowacnJxQpUoVTJ8+3fD6hAkTULNmTZQrVw7Vq1fH5MmTDWcJLV++HHFxcTh58qRhD9Ty5csBmO5BOnXqFFq2bAkXFxd4eXlhyJAhuHfvnuH1AQMGoHPnzpgzZw78/Pzg5eWF4cOH84wkIqICJKZmmeyJeZoAkK7OQWJqltVq4h6Z57FhA9C1KyCeyabXruna168HunSRpranzJkzB1OmTEFsbCwAYMGCBfjxxx/x/fffo0qVKrh69SquXr0KADhy5Ah8fHywbNkyREVFWXzNlGvXrqF9+/YYMGAAvv32W5w5cwZvv/02nJ2dMXXqVADApEmTsHTpUsydOxeRkZFIT0/HmTNnDMtwc3PD8uXL4e/vj1OnTuHtt9+Gm5sb3nvvPfTo0QPJyclISEjA7t27AQAqlcqkjvv376Nt27Zo3Lgxjhw5gszMTLz11lsYMWKEIfgAwL59++Dn54d9+/bhwoUL6NGjB+rXr4+33367KF1MRFQmZN7NO8QUZbriwCBTVBoNMHq0aYgBdG0KBTBmDNCpU4kcZtq6dSvKly9veN6uXTusW7fO7LQtW7bEu+++a3h+5coVhISEIDIyEgqFAlWrVjW85u3tDeCfS/Jb6ssvv0RgYCAWLVoEhUKBWrVq4fr165gwYQKmTJmC+/fvY/78+Vi0aBH69+8PAAgODkZkZKRhGR9++KHh/6tVq4b//Oc/WLNmDd577z24uLigfPnysLe3z7eu1atXIycnB99++y1cXV0BAIsWLUJ0dDRmzpyJSpUqAQA8PDywaNEiKJVK1KpVCx06dMCePXsYZIiI8uHj5lys0xUHBpmi+uUX4K+/8n5dCODqVd10zZsX+9u3aNEC8fHxhuf6H21zGjVqZPR8wIABeO211/DCCy8gKioKr7/+Otq0afNc9Zw+fRqNGzc2ut5KREQE7t27h7/++gsZGRnIzc1Fq1at8lzG2rVrsWDBAly8eBH37t3DkydP4O7uXug66tWrZ9QfERER0Gq1OHv2rCHI1KlTx2hvk5+fH06dOlWo9yIiKmvCgzzhp3JGhjrH7DgZBQBfle5UbGvhGJmiSrdwVLal0xWSq6sratSoYXjkdw+gZ0NOgwYNkJqaimnTpuHhw4fo3r07unbtWiJ16ukv85+Xw4cPo3fv3mjfvj22bt2K48eP44MPPsCjR49KpB4HBwej5wqFAlqttkTei4iotFDaKRAbHQpAF1qepn8eGx1q1evJMMgUlaU3D7TRmwy6u7ujR48eWLp0KdauXYsffvgBWVm6wVkODg7QaDSFWl7t2rVx+PBhiKcOtR08eBBubm4ICAhASEgIXFxcsGfPHrPzHzp0CFWrVsUHH3yARo0aISQkBGlpaUbTODo6FlhX7dq1cfLkSdy/f9+oDjs7O7zwwguF+kxERGQqqq4f4vs0gK/K+PCRr8oZ8X0aWP06Mjy0VFRNmwIBAbqBvebGySgUutebNrV+bQX4/PPP4efnh5deegl2dnZYt24dfH19UaFCBQC68Sl79uxBREQEnJyc4OHhUeAy33nnHcybNw8jR47EiBEjcPbsWcTGxmLcuHGws7ODs7MzJkyYgPfeew+Ojo6IiIjAzZs38eeff2Lw4MEICQnBlStXsGbNGrz88sv46aefsHHjRqP3qFatGlJTU3HixAkEBATAzc0NTk5ORtP07t0bsbGx6N+/P6ZOnYqbN29i5MiR6Nu3r+GwEhERPZ+oun54LdTXJq7syz0yRaVUAvPn6/7/2fvw6J/Pm1fi15MpCjc3N8yaNQuNGjXCyy+/jMuXL2Pbtm2ws9OtDp999hl27dqFwMBAvPTSSxYts3Llyti2bRsSExNRr149DBs2DIMHDzYawDt58mS8++67mDJlCmrXro0ePXogMzMTANCxY0eMHTsWI0aMQP369XHo0CFMnjzZ6D3efPNNREVFoUWLFvD29sZ3331nUke5cuWwY8cOZGVl4eWXX0bXrl3RqlUrLFq0qKjdRUREZijtFGgc7IVO9SujcbCXJCEGABRCmNudUHpkZ2dDpVJBrVabDBzNyclBamoqgoKC4OxcxBHWGzbozl56euBvYKAuxNjAqddUPIplXSEiIovl9/v9NB5ael5duuhOsbbylX2JiIiIQaZ4KJUlcoo1ERER5Y9jZIiIiEi2GGSIiIhIthhkAJTy8c5UDLiOEBHZpjIdZPRXd33w4IHElZCt068jz14RmIiIpFWmB/sqlUpUqFDBcC2TcuXKGd0riEgIgQcPHiAzMxMVKlSw+G7gRERkHWU6yAAw3ElZH2aIzCns3cCJiMg6ynyQUSgU8PPzg4+PDx4/fix1OWSDHBwcuCeGiMhGlfkgo6dUKvljRUREJDNlerAvERERyRuDDBEREckWgwwRERHJlqRB5sCBA4iOjoa/vz8UCgU2bdpkeO3x48eYMGECwsLC4OrqCn9/f/Tr1w/Xr1+XrmAiKlU0WoHDF29h84lrOHzxFjRaXviQSG4kHex7//591KtXD4MGDUKXLl2MXnvw4AGSkpIwefJk1KtXD7dv38bo0aPRsWNHHD16VKKKiai0SEhOR9yWFKSrcwxtfipnxEaHIqqun4SVEVFhKISNXHtdoVBg48aN6Ny5c57THDlyBOHh4UhLS0OVKlUsWm52djZUKhXUajXc3d2LqVoikrOE5HTErEzCsxs//eUw4/s0YJghkpilv9+yGiOjVquhUChQoUIFqUshIpnSaAXitqSYhBgAhra4LSk8zEQkE7IJMjk5OZgwYQJ69eqVbzLLzc1Fdna20YOISC8xNcvocNKzBIB0dQ4SU7OsVxQRFZksgszjx4/RvXt3CCEQHx+f77QzZsyASqUyPAIDA61UJRHJQebdvENMUaYjImnZfJDRh5i0tDTs2rWrwHEukyZNglqtNjyuXr1qpUqJSA583JyLdToikpZN36JAH2LOnz+Pffv2wcvLq8B5nJyc4OTkZIXqiEiOwoM84adyRoY6x+w4GQUAX5UzwoM8rV0aERWBpHtk7t27hxMnTuDEiRMAgNTUVJw4cQJXrlzB48eP0bVrVxw9ehSrVq2CRqNBRkYGMjIy8OjRIynLJiIZU9opEBsdCuCfs5T09M9jo0OhtHv2VSKyRZKefv3zzz+jRYsWJu39+/fH1KlTERQUZHa+ffv2oXnz5ha9B0+/JiJzeB0ZIttm6e+3zVxHpqQwyBBRXjRagcTULGTezYGPm+5wEvfEENkGS3+/bXqMDBFRSVLaKdA4uOCxd0Rku2z+rCUiIiKivDDIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFs2UtdAD0fjVYgMTULmXdz4OPmjPAgTyjtFFKXJTn2CxFR2SBpkDlw4ABmz56NY8eOIT09HRs3bkTnzp0NrwshEBsbi6VLl+LOnTuIiIhAfHw8QkJCpCvahiQkpyNuSwrS1TmGNj+VM2KjQxFV10/CyqTFfiEiKjskPbR0//591KtXD1988YXZ12fNmoUFCxZg8eLF+P333+Hq6oq2bdsiJyfH7PRlSUJyOmJWJhn9WANAhjoHMSuTkJCcLlFl0mK/EBGVLQohhJC6CABQKBRGe2SEEPD398e7776L//znPwAAtVqNSpUqYfny5ejZs6dFy83OzoZKpYJarYa7u3tJlW9VGq1A5My9Jj/WegoAvipn/DqhZZk6nMJ+ISIqPSz9/bbZwb6pqanIyMhA69atDW0qlQqvvPIKDh8+nOd8ubm5yM7ONnqUNompWXn+WAOAAJCuzkFiapb1irIB7BciorLHZoNMRkYGAKBSpUpG7ZUqVTK8Zs6MGTOgUqkMj8DAwBKtUwqZdy07tGbpdKUF+4WIqOyx2SBTVJMmTYJarTY8rl69KnVJxc7HzblYpyst2C9ERGWPzQYZX19fAMCNGzeM2m/cuGF4zRwnJye4u7sbPUqb8CBP+KmckdcoDwV0Z+mEB3lasyzJsV+IiMoemw0yQUFB8PX1xZ49ewxt2dnZ+P3339G4cWMJK5Oe0k6B2OhQADD50dY/j40OLXMDWtkvRERlj6RB5t69ezhx4gROnDgBQDfA98SJE7hy5QoUCgXGjBmDjz/+GD/++CNOnTqFfv36wd/f3+haM2VVVF0/xPdpAF+V8WESX5Uz4vs0KLPXS2G/EBGVLZKefv3zzz+jRYsWJu39+/fH8uXLDRfEW7JkCe7cuYPIyEh8+eWXqFmzpsXvURpPv34ar2BrHvuFiEjeLP39tpnryJSU0h5kiIiISiPZX0eGiIiIqCAMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFsMMkRERCRbDDJEREQkWwwyREREJFv2UhdApYdGK5CYmoXMuznwcXNGeJAnlHYKqcsikgV+f4iKxqaDjEajwdSpU7Fy5UpkZGTA398fAwYMwIcffgiFgl9wW5KQnI64LSlIV+cY2vxUzoiNDkVUXT8JKyOyffz+EBWdTR9amjlzJuLj47Fo0SKcPn0aM2fOxKxZs7Bw4UKpS6OnJCSnI2ZlktFGGAAy1DmIWZmEhOR0iSojsn38/hA9H5sOMocOHUKnTp3QoUMHVKtWDV27dkWbNm2QmJgodWn0/zRagbgtKRBmXtO3xW1JgUZrbgqiso3fH6LnZ9NBpkmTJtizZw/OnTsHADh58iR+/fVXtGvXLs95cnNzkZ2dbfSgkpOYmmXyl+TTBIB0dQ4SU7OsVxSRTPD7Q/T8bHqMzMSJE5GdnY1atWpBqVRCo9Fg+vTp6N27d57zzJgxA3FxcVassmzLvJv3Rrgo0xGVJfz+ED0/m94j8/3332PVqlVYvXo1kpKSsGLFCsyZMwcrVqzIc55JkyZBrVYbHlevXrVixWWPj5tzsU5HVJbw+0P0/Gx6j8z48eMxceJE9OzZEwAQFhaGtLQ0zJgxA/379zc7j5OTE5ycnKxZZpkWHuQJP5UzMtQ5Zo/zKwD4qnSnkhKRMX5/iJ6fTe+RefDgAezsjEtUKpXQarUSVUTPUtopEBsdCkC30X2a/nlsdCivh0FkBr8/RM/PpoNMdHQ0pk+fjp9++gmXL1/Gxo0b8fnnn+ONN96QujR6SlRdP8T3aQBflfHub1+VM+L7NOB1MIjywe8P0fNRCCFs9ry+u3fvYvLkydi4cSMyMzPh7++PXr16YcqUKXB0dLRoGdnZ2VCpVFCr1XB3dy/hiss2XpmUqOj4/SEyZunvt00HmeLAIENERCQ/lv5+2/ShJSIiIqL8MMgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbDHIEBERkWwxyBAREZFsMcgQERGRbBU6yCQkJODXX381PP/iiy9Qv359/Pvf/8bt27eLtTgiKp00WoHDF29h84lrOHzxFjRaIXVJRCRThQ4y48ePR3Z2NgDg1KlTePfdd9G+fXukpqZi3LhxxV4gEZUuCcnpiJy5F72W/obRa06g19LfEDlzLxKS06UujYhkqNBBJjU1FaGhoQCAH374Aa+//jo++eQTfPHFF9i+fXuxF0hEpUdCcjpiViYhXZ1j1J6hzkHMyiSGGSIqtEIHGUdHRzx48AAAsHv3brRp0wYA4OnpadhTQ0T0LI1WIG5LCswdRNK3xW1J4WEmIioU+8LOEBkZiXHjxiEiIgKJiYlYu3YtAODcuXMICAgo9gKJqHRITM0y2RPzNAEgXZ2DxNQsNA72sl5hRCRrhd4js2jRItjb22P9+vWIj49H5cqVAQDbt29HVFRUsRdIRKVD5t28Q0xRpiMiAoqwR6ZKlSrYunWrSfvcuXOLpSAiKp183JyLdToiIsDCIJOdnQ13d3fD/+dHPx0R0dPCgzzhp3JGhjrH7DgZBQBflTPCgzytXRoRyZhFQcbDwwPp6enw8fFBhQoVoFAoTKYRQkChUECj0RR7kUQkf0o7BWKjQxGzMgkKwCjM6LcosdGhUNqZbl+IiPJiUZDZu3cvPD09Df9vLsgQERUkqq4f4vs0QNyWFKOBv74qZ8RGhyKqrp+E1RGRHCmEEKX6XMfs7GyoVCqo1Woe9iKyERqtQGJqFjLv5sDHTXc4iXtiiOhplv5+F/qspalTp0Kr1Zq0q9Vq9OrVq7CLI6IySGmnQONgL3SqXxmNg70YYoioyAodZL7++mtERkbi0qVLhraff/4ZYWFhuHjxYrEWR0RERJSfQgeZP/74AwEBAahfvz6WLl2K8ePHo02bNujbty8OHTpUEjUSERERmVXo68h4eHjg+++/x/vvv4+hQ4fC3t4e27dvR6tWrUqiPiIiIqI8FXqPDAAsXLgQ8+fPR69evVC9enWMGjUKJ0+eLO7aiIiIiPJV6CATFRWFuLg4rFixAqtWrcLx48fx6quv4l//+hdmzZpVEjUSERERmVXoIKPRaPDHH3+ga9euAAAXFxfEx8dj/fr1vE0BERERWVWxXkfm77//RsWKFYtrccWC15EhIiKSnxK7jkx+bC3EEBERUelW6LOWNBoN5s6di++//x5XrlzBo0ePjF7PysoqtuKIiIiI8lPoPTJxcXH4/PPP0aNHD6jVaowbNw5dunSBnZ0dpk6dWgIlEhEREZlX6CCzatUqLF26FO+++y7s7e3Rq1cv/Pe//8WUKVPw22+/lUSNRERERGYVOshkZGQgLCwMAFC+fHmo1WoAwOuvv46ffvqpeKsjIiIiykehg0xAQADS09MBAMHBwdi5cycA4MiRI3Bycire6oiIiIjyUegg88Ybb2DPnj0AgJEjR2Ly5MkICQlBv379MGjQoGIvkIiIiCgvz30dmcOHD+Pw4cMICQlBdHR0cdVVbHgdGSIiIvmx2nVkGjdujHHjxpVYiLl27Rr69OkDLy8vuLi4ICwsDEePHi2R9yIiIiJ5ea4g4+7ujkuXLhVXLSZu376NiIgIODg4YPv27UhJScFnn30GDw+PEntPIiIikg+LL4h3/fp1+Pv7G7UV490NzJo5cyYCAwOxbNkyQ1tQUFCJvicRERHJh8V7ZOrUqYPVq1eXZC0mfvzxRzRq1AjdunWDj48PXnrpJSxdujTfeXJzc5GdnW30ICIiotLJ4iAzffp0DB06FN26dTPchqBPnz4lOoD20qVLiI+PR0hICHbs2IGYmBiMGjUKK1asyHOeGTNmQKVSGR6BgYElVh8RERFJq1BnLaWmpmLw4MFISUnB0qVLS/wsJUdHRzRq1AiHDh0ytI0aNQpHjhzB4cOHzc6Tm5uL3Nxcw/Ps7GwEBgbyrCUiIiIZsfSspULdNDIoKAh79+7FokWL0KVLF9SuXRv29saLSEpKKlrFZvj5+SE0NNSorXbt2vjhhx/ynMfJyYkX5iMiIiojCn3367S0NGzYsAEeHh7o1KmTSZApThERETh79qxR27lz51C1atUSe08iIiKSj0KlEP3NIlu3bo0///wT3t7eJVUXAGDs2LFo0qQJPvnkE3Tv3h2JiYlYsmQJlixZUqLvS0RERPJg8RiZqKgoJCYmYt68eejXr19J12WwdetWTJo0CefPn0dQUBDGjRuHt99+2+L5eWVfIiIi+Sn2MTIajQZ//PEHAgICiqVAS73++ut4/fXXrfqeREREJA8WB5ldu3aVZB1EREREhfbc91oiIiIikgqDDBEREckWgwwRERHJFoMMERERyRaDDBEREckWgwwRERHJFoMMERERyRaDDBEREckWgwwRERHJFoMMERERyRaDDBEREckWgwwRERHJFoMMERERyZbFd7+mf2i0AompWci8mwMfN2eEB3lCaaeQuiwiIqIyh0GmkBKS0xG3JQXp6hxDm5/KGbHRoYiq6ydhZURERGUPDy0VQkJyOmJWJhmFGADIUOcgZmUSEpLTJaqMiIiobGKQsZBGKxC3JQXCzGv6trgtKdBozU1BREREJYFBxkKJqVkme2KeJgCkq3OQmJplvaKIiIjKOAYZC2XezTvEFGU6IiIien4MMhbycXMu1umIiIjo+THIWCg8yBN+KmfkdZK1Arqzl8KDPK1ZFhERUZnGIGMhpZ0CsdGhAGASZvTPY6NDeT0ZIiIiK2KQKYSoun6I79MAvirjw0e+KmfE92nA68gQERFZGS+IV0hRdf3wWqgvr+xLRERkAxhkikBpp0DjYC+pyyAiIirzeGiJiIiIZItBhoiIiGSLQYaIiIhki0GGiIiIZItBhoiIiGSLQYaIiIhki0GGiIiIZItBhoiIiGSLQYaIiIhki0GGiIiIZItBhoiIiGSLQYaIiIhki0GGiIiIZItBhoiIiGRLVkHm008/hUKhwJgxY6QuhYioWGm0Aocv3sLmE9dw+OItaLRC6pKIZMFe6gIsdeTIEXz11Vd48cUXpS6FiKhYJSSnI25LCtLVOYY2P5UzYqNDEVXXT8LKiGyfLPbI3Lt3D71798bSpUvh4eEhdTlERMUmITkdMSuTjEIMAGSocxCzMgkJyekSVUYkD7IIMsOHD0eHDh3QunVrqUshIio2Gq1A3JYUmDuIpG+L25LCw0xE+bD5Q0tr1qxBUlISjhw5YtH0ubm5yM3NNTzPzs4uqdKIiJ5LYmqWyZ6YpwkA6eocJKZmoXGwl/UKI5IRm94jc/XqVYwePRqrVq2Cs7OzRfPMmDEDKpXK8AgMDCzhKomIiibzbt4hpijTEZVFNh1kjh07hszMTDRo0AD29vawt7fH/v37sWDBAtjb20Oj0ZjMM2nSJKjVasPj6tWrElRORFQwHzfL/kCzdDqissimDy21atUKp06dMmobOHAgatWqhQkTJkCpVJrM4+TkBCcnJ2uVSERUZOFBnvBTOSNDnWN2nIwCgK/KGeFBntYujUg2bDrIuLm5oW7dukZtrq6u8PLyMmknIpIbpZ0CsdGhiFmZBAVgFGYU///f2OhQKO0UZuYmIsDGDy0REZV2UXX9EN+nAXxVxoePfFXOiO/TgNeRISqAQghRqs/ry87Ohkqlglqthru7u9TlEBGZpdEKJKZmIfNuDnzcdIeTuCeGyjJLf79t+tASEVFZobRT8BRroiLgoSUiIiKSLQYZIiIiki0GGSIiIpItBhkiIiKSLQYZIiIiki0GGSIiIpItBhkiIiKSLV5HRu4ePwYyMgCtFqhYEXB1lboiIiIiq+EeGTm6eROYNQv4178ANzegShWgWjXd/4eGAqNHA6dPS10lERFRieMeGTl58gT47DNg6lRACKBTJ6BnT+CFFwClEvjrL+DoUWDNGmDBAqB3b2D+fMCLVwslIqLSifdakos7d3TB5ddfgbFjgYkTdYeSzHn0CPjf/4D//AdwcQG2bwfq1bNquURERM/D0t9vHlqSg4cPgXbtgFOngJ9/BubMyTvEAICjIzB4MPDnn4C/P9CyJXDmjNXKJSIishYGGTmYPBk4fhzYuRNo2tTy+fz9gV27AB8foE8f3cBgIiKiUoRBxtadPAl8/jkwbRrQqJHJyxqtwOGLt7D5xDUcvngLGu0zRwo9PIBvv9UFoS++sFLRNkCj0e29+u473X81GqkrIiKiEsAxMrburbd0e2IuXQLsjcdmJySnI25LCtLVOYY2P5UzYqNDEVXXz3g5ffsCBw8CFy4AdqU8v27YoDtz66+//mkLCNANfO7SRbq6iIjIYhwjUxo8fqw7A+mtt8yGmJiVSUYhBgAy1DmIWZmEhOR042XFxACpqbowU5pt2AB07WocYgDg2jVd+4YN0tRFREQlgkHGlqWkAPfvAy1aGDVrtAJxW1Jgbleavi1uS4rxYabwcN0ZTImJJVau5DQa3Z4YczsZ9W1jxvAwExFRKcIgY8v0F7V78UWj5sTULJM9MU8TANLVOUhMzfqn0d4eqFOndF8o75dfTPfEPE0I4OpV3XRERFQqMMjYspz/Dyvlyhk1Z97NO8TkO125cv8sszRKTy94msJMR0RENo9BxpbpBzdlZRk1+7g5WzS7yXRZWf8sszTy8yt4msJMR0RENo9Bxpbpr8Z7/LhRc3iQJ/xUzlDkMZsCurOXwoM8/2l8+FB3WKk0X+G3aVPd2UmKPHpGoQACAwt3LR4iIrJpDDK2rHp1wNcX2LLFqFlpp0BsdCgAmIQZ/fPY6FAo7Z56dccO3SDXiIiSq1dqSqXuFGvANMzon8+bp5uOiIhKBQYZW6ZQ6E69/t//dPdaekpUXT/E92kAX5Xx4SNflTPi+zQwvo6MELqbSP7rX0DdulYoXEJdugDr1wOVKxu3BwTo2nkdGSKiUoUXxLN1168DtWoB3boBX39t8rJGK5CYmoXMuznwcdMdTjLaEwPorm77738DmzbpbjxZFmg0urOT0tN1Y2KaNuWeGCIiGbH095tBRg6WLgWGDAGWLQMGDCjcvKdOAa++CrRtq7u4HhERkQxY+vttn+crZDveegs4ehQYNAi4eRMYN86yvQu7dgG9egHVqgGLF5d4mURERNbGMTJyoFAA8fHAe+8BEyboDpMkJABarfnpk5OBgQOBNm2Al14C9u4FKlSwaslERETWwD0ycmFnB3z6KdChAzByJNCunW4A6yuvAC+8oLty79Wruj03p07pznaKjweGDs37dGQiIiKZ4xgZORICOHxYdwPEY8d0d8bWagEfH6BhQ6B1a6BzZ8DRUepKiYiIioRjZEozhQJo0kT3ICIiKsM4RoaIiIhki0GGiIiIZItBhoiIiGSLY2SoeOTmAn/+qbvOjVIJBAfrrl9Tls+YEgK4fBm4eFF3pWFvb6BOHcDJSerKiIhKDQYZKrrHj3W3PYiPB379Vff8aZ6eQNeuwPDhwIsvSlKiJP74A/jyS929nW7dMn7NwQGIjASGDQPeeEP3nIiIioyHlqhoTp4EXn4Z6N4dePIE+Pxz3SnhaWm6PRA//aT7sd66FahXD3jnHeDePamrLln37ulCW716ujuWDxmi+/wXLuj65fBhYO5c3d6ZHj10/XfihNRVExHJGq8jQ4W3fr3uJpQvvKC7kWV4eN7TPn6suz3CxIlA1aq62yY8e2fq0uD6deC113SHkmbMAGJi8t/bcuQIMHgwcOYMsHKlLhASEZGBpb/f3CNDhbNzJ9Czp+6Q0dGj+YcYQPdjPnIkkJSk22PRujWQnW2dWq3l7l1diMnO1vXJqFEFHzJ6+WXdtN2760Lhjh3WqZWIqJRhkCHL3b6tu/t269bAt98WbtDqCy8Au3cDf/0FjB9fYiVKYvx43aGj3buB2rUtn8/REVixQheCBgwAsrJKrEQiotKKQYYsFxcHPHigO5xkbzpOXKMVOHzxFjafuIbDF29Bo33mqGXNmsDs2cCSJbpbK5QGSUnAV18Bs2bpwtozCuwTpVLXnzk5wNSp1qmZiGStwO1KGWPTY2RmzJiBDRs24MyZM3BxcUGTJk0wc+ZMvGDmByMvHCNTTO7d041tGTECmD7d5OWE5HTEbUlBujrH0OanckZsdCii6vr9M6FGozs1u0ULYNkya1ResgYNAvbs0d3vSqk0esniPgGADz8EFiwArl0D3NysUTkRyVChtisyVyrGyOzfvx/Dhw/Hb7/9hl27duHx48do06YN7t+/L3VpZU9Cgm4MyNtvm76UnI6YlUlGXywAyFDnIGZlEhKS0/9pVCp1g1zXrdOFGjnTaHQDnwcNMhtiLO4TQNevd+/q+pmIyIxCb1fKCJsOMgkJCRgwYADq1KmDevXqYfny5bhy5QqOlZbDEnJy9Khuj0y1akbNGq1A3JYUmNutp2+L25JivOszIgK4fx84e7akqrWOc+d04SMy0qi5SH1StSoQEKDrZyKiZxRpu1JG2HSQeZZarQYAeHp65jlNbm4usrOzjR5UDC5cAEJDTZoTU7NM/jp4mgCQrs5BYupTA1n1yzl/vpiLtLILF3T/faZfitQn+uXIvU+IqEQUebtSBsgmyGi1WowZMwYRERGoW7duntPNmDEDKpXK8AgMDLRilaXYkye6s2yekXk37y9WntPpl/PkSXFUJh39lYyf6Zci9Yl+OXLvEyIqEUXerpQBsgkyw4cPR3JyMtasWZPvdJMmTYJarTY8rl69aqUKSzlPTyDd9Pirj5uzRbMbTZeRofuvl1dxVCYdff3P9EuR+gTQ9Yvc+4SISkSRtytlgCyCzIgRI7B161bs27cPAQEB+U7r5OQEd3d3owcVgwYNgORk3c0hnxIe5Ak/lTPyujWkAroR9eFBTx0O1I9xql+/JCq1Hn39z4zZKlKfPHqku0dTgwYlUSkRyVyRtitlhE0HGSEERowYgY0bN2Lv3r0ICgqSuqSyq0UL3Y/tli1GzUo7BWKjdWNEnv2C6Z/HRodCaffUq+vX636wK1QosXKtQqUCGjbUfZ6nFKlPtmzR9W/z5iVWLhHJV5G2K2WETQeZ4cOHY+XKlVi9ejXc3NyQkZGBjIwMPHz4UOrSyp46dXRn58ydCzxz6aGoun6I79MAvirjXZq+KmfE92lgfG2Dc+d0N1IcNswaVZe8YcN0N8g8c8aouVB9IoSuXyMigLAwa1RNRDJUqO1KGWLTF8RTKMwny2XLlmHAgAEWLYMXxCtGu3YBbdrormQ7ZIjJyxqtQGJqFjLv5sDHTbeL0+ivA61Wt8fh2jXg1CmgXDnr1V5SHj7UhQ9fX2D/fpPryRTYJwCwdKmuP3fs0PUvEVE+LNqulAKW/n7bdJApDgwyxWzIEN3dmhMSgFdftXw+IYCxY3VXr923D2jWrORqtLYDB3QBbcQIYP58II8AbtYvvwBt2+puHPnf/5ZYiUREclMqruxLNmjBAqBJEyAqSnePIEty8O3bQO/euh/5L78sXSEG0AW6+Hhg4UJdILHk5o9C6PqvbVugcWPdvEREVGgMMlQ4zs66gak9ewJvvQW0bKl7bu52A7duAXPm6MbXbNsGrF5desbGPGvoUGDNGmD7dt3nnT0b+Ptv0+k0Gl1/tWql678ePXRjhlxcrF8zEVEpwENLVHTbtwOxscCRI4CrK/DSS4C/v+6ibmfOAKdPAw4Ouh/rGTN0tzgo7a5dA95/XxdqHj8GatUCatfW3S38+nXg+HHd7RkaNdLdTbx9e6krJiKySRwj8/8YZKzg2DFg714gKQm4eVM34LVGDd2pydHRgLe31BVa382buj0vx47pbmWg0QAVK+r6pGVL3X+JiChPDDL/j0GGiIhIfjjYl4iIiEo9BhkiIiKSLQYZIiIiki0GGSIiIpItBhkiIiKSLQYZIiIiki0GGSIiIpItBhkiIiKSLQYZIiIiki17qQuQI41WIDE1C5l3c+Dj5ozwIE8o7RRSl0VERFTmMMgUUkJyOuK2pCBdnWNo81M5IzY6FFF1/SSsjIiIqOzhoaVCSEhOR8zKJKMQAwAZ6hzErExCQnK6RJURERGVTQwyFtJoBeK2pMDcHTb1bXFbUqDRlup7cBIREdkUBhkLJaZmmeyJeZoAkK7OQWJqlvWKIiIiKuMYZCyUeTfvEFOU6YiIiOj5MchYyMfNuVinIyIioufHIGOh8CBP+KmckddJ1grozl4KD/K0ZllERERlGoOMhZR2CsRGhwKASZjRP4+NDuX1ZIiIiKyIQaYQour6Ib5PA/iqjA8f+aqcEd+nAa8jQ0REZGW8IF4hRdX1w2uhvryyLxERkQ1gkCkCpZ0CjYO9pC6DiIiozOOhJSIiIpItBhkiIiKSLQYZIiIiki0GGSIiIpItBhkiIiKSLQYZIiIiki0GGSIiIpItBhkiIiKSLQYZIiIiki0GGSIiIpItBhkiIiKSLQYZIiIiki0GGSIiIpItBhkiIiKSLXupCyAikopGK5CYmoXMuznwcXNGeJAnlHYKqcsiokKQRZD54osvMHv2bGRkZKBevXpYuHAhwsPDpS6LiGQsITkdcVtSkK7OMbT5qZwRGx2KqLp+ElZGRIVh84eW1q5di3HjxiE2NhZJSUmoV68e2rZti8zMTKlLIyKZSkhOR8zKJKMQAwAZ6hzErExCQnK6RJURUWHZfJD5/PPP8fbbb2PgwIEIDQ3F4sWLUa5cOXzzzTdSl0ZEMqTRCsRtSYEw85q+LW5LCjRac1MQka2x6SDz6NEjHDt2DK1btza02dnZoXXr1jh8+LDZeXJzc5GdnW30ICLSS0zNMtkT8zQBIF2dg8TULOsVRURFZtNB5u+//4ZGo0GlSpWM2itVqoSMjAyz88yYMQMqlcrwCAwMtEapRCQTmXfzDjFFmY6IpGXTQaYoJk2aBLVabXhcvXpV6pKIyIb4uDkX63REJC2bPmupYsWKUCqVuHHjhlH7jRs34Ovra3YeJycnODk5WaM8IpKh8CBP+KmckaHOMTtORgHAV6U7FZuIbJ9N75FxdHREw4YNsWfPHkObVqvFnj170LhxYwkrIyK5UtopEBsdCkAXWp6mfx4bHcrryRDJhE0HGQAYN24cli5dihUrVuD06dOIiYnB/fv3MXDgQKlLIyKZiqrrh/g+DeCrMj585KtyRnyfBryODJGM2PShJQDo0aMHbt68iSlTpiAjIwP169dHQkKCyQBgIqLCiKrrh9dCfXllXyKZUwghSvXFErKzs6FSqaBWq+Hu7i51OURERGQBS3+/bf7QEhEREVFeGGSIiIhIthhkiIiISLYYZIiIiEi2GGSIiIhIthhkiIiISLYYZIiIiEi2GGSIiIhIthhkiIiISLZs/hYFz0t/4eLs7GyJKyEiIiJL6X+3C7oBQakPMnfv3gUABAYGSlwJERERFdbdu3ehUqnyfL3U32tJq9Xi+vXrcHNzg0JROm8Gl52djcDAQFy9epX3k3oK+8UU+8QU+8Q89osp9ompkuwTIQTu3r0Lf39/2NnlPRKm1O+RsbOzQ0BAgNRlWIW7uzu/XGawX0yxT0yxT8xjv5hin5gqqT7Jb0+MHgf7EhERkWwxyBAREZFsMciUAk5OToiNjYWTk5PUpdgU9osp9okp9ol57BdT7BNTttAnpX6wLxEREZVe3CNDREREssUgQ0RERLLFIENERESyxSBDREREssUgI2MzZszAyy+/DDc3N/j4+KBz5844e/as1GXZlE8//RQKhQJjxoyRuhRJXbt2DX369IGXlxdcXFwQFhaGo0ePSl2WpDQaDSZPnoygoCC4uLggODgY06ZNK/C+LqXJgQMHEB0dDX9/fygUCmzatMnodSEEpkyZAj8/P7i4uKB169Y4f/68NMVaUX798vjxY0yYMAFhYWFwdXWFv78/+vXrh+vXr0tXsBUUtK48bdiwYVAoFJg3b55VamOQkbH9+/dj+PDh+O2337Br1y48fvwYbdq0wf3796UuzSYcOXIEX331FV588UWpS5HU7du3ERERAQcHB2zfvh0pKSn47LPP4OHhIXVpkpo5cybi4+OxaNEinD59GjNnzsSsWbOwcOFCqUuzmvv376NevXr44osvzL4+a9YsLFiwAIsXL8bvv/8OV1dXtG3bFjk5OVau1Lry65cHDx4gKSkJkydPRlJSEjZs2ICzZ8+iY8eOElRqPQWtK3obN27Eb7/9Bn9/fytVBkBQqZGZmSkAiP3790tdiuTu3r0rQkJCxK5du0SzZs3E6NGjpS5JMhMmTBCRkZFSl2FzOnToIAYNGmTU1qVLF9G7d2+JKpIWALFx40bDc61WK3x9fcXs2bMNbXfu3BFOTk7iu+++k6BCaTzbL+YkJiYKACItLc06RUksrz7566+/ROXKlUVycrKoWrWqmDt3rlXq4R6ZUkStVgMAPD09Ja5EesOHD0eHDh3QunVrqUuR3I8//ohGjRqhW7du8PHxwUsvvYSlS5dKXZbkmjRpgj179uDcuXMAgJMnT+LXX39Fu3btJK7MNqSmpiIjI8PoO6RSqfDKK6/g8OHDElZme9RqNRQKBSpUqCB1KZLRarXo27cvxo8fjzp16lj1vUv9TSPLCq1WizFjxiAiIgJ169aVuhxJrVmzBklJSThy5IjUpdiES5cuIT4+HuPGjcP777+PI0eOYNSoUXB0dET//v2lLk8yEydORHZ2NmrVqgWlUgmNRoPp06ejd+/eUpdmEzIyMgAAlSpVMmqvVKmS4TUCcnJyMGHCBPTq1atM30hy5syZsLe3x6hRo6z+3gwypcTw4cORnJyMX3/9VepSJHX16lWMHj0au3btgrOzs9Tl2AStVotGjRrhk08+AQC89NJLSE5OxuLFi8t0kPn++++xatUqrF69GnXq1MGJEycwZswY+Pv7l+l+Ics9fvwY3bt3hxAC8fHxUpcjmWPHjmH+/PlISkqCQqGw+vvz0FIpMGLECGzduhX79u1DQECA1OVI6tixY8jMzESDBg1gb28Pe3t77N+/HwsWLIC9vT00Go3UJVqdn58fQkNDjdpq166NK1euSFSRbRg/fjwmTpyInj17IiwsDH379sXYsWMxY8YMqUuzCb6+vgCAGzduGLXfuHHD8FpZpg8xaWlp2LVrV5neG/PLL78gMzMTVapUMWx309LS8O6776JatWol/v7cIyNjQgiMHDkSGzduxM8//4ygoCCpS5Jcq1atcOrUKaO2gQMHolatWpgwYQKUSqVElUknIiLC5LT8c+fOoWrVqhJVZBsePHgAOzvjv+WUSiW0Wq1EFdmWoKAg+Pr6Ys+ePahfvz4AIDs7G7///jtiYmKkLU5i+hBz/vx57Nu3D15eXlKXJKm+ffuajEds27Yt+vbti4EDB5b4+zPIyNjw4cOxevVqbN68GW5ubobj1iqVCi4uLhJXJw03NzeTMUKurq7w8vIqs2OHxo4diyZNmuCTTz5B9+7dkZiYiCVLlmDJkiVSlyap6OhoTJ8+HVWqVEGdOnVw/PhxfP755xg0aJDUpVnNvXv3cOHCBcPz1NRUnDhxAp6enqhSpQrGjBmDjz/+GCEhIQgKCsLkyZPh7++Pzp07S1e0FeTXL35+fujatSuSkpKwdetWaDQaw7bX09MTjo6OUpVdogpaV54Ncw4ODvD19cULL7xQ8sVZ5dwoKhEAzD6WLVsmdWk2payffi2EEFu2bBF169YVTk5OolatWmLJkiVSlyS57OxsMXr0aFGlShXh7OwsqlevLj744AORm5srdWlWs2/fPrPbkP79+wshdKdgT548WVSqVEk4OTmJVq1aibNnz0pbtBXk1y+pqal5bnv37dsndeklpqB15VnWPP1aIUQZuowlERERlSoc7EtERESyxSBDREREssUgQ0RERLLFIENERESyxSBDREREssUgQ0RERLLFIENERESyxSBDRGXCzz//DIVCgTt37khdChEVIwYZIrIqjUaDJk2aoEuXLkbtarUagYGB+OCDD0rkfZs0aYL09HSoVKoSWT4RSYNX9iUiqzt37hzq16+PpUuXonfv3gCAfv364eTJkzhy5EipvV8NERU/7pEhIqurWbMmPv30U4wcORLp6enYvHkz1qxZg2+//TbPEDNhwgTUrFkT5cqVQ/Xq1TF58mQ8fvwYgO5O8K1bt0bbtm2h/9ssKysLAQEBmDJlCgDTQ0tpaWmIjo6Gh4cHXF1dUadOHWzbtq3kPzwRFSve/ZqIJDFy5Ehs3LgRffv2xalTpzBlyhTUq1cvz+nd3NywfPly+Pv749SpU3j77bfh5uaG9957DwqFAitWrEBYWBgWLFiA0aNHY9iwYahcubIhyDxr+PDhePToEQ4cOABXV1ekpKSgfPnyJfVxiaiE8NASEUnmzJkzqF27NsLCwpCUlAR7e8v/tpozZw7WrFmDo0ePGtrWrVuHfv36YcyYMVi4cCGOHz+OkJAQALo9Mi1atMDt27dRoUIFvPjii3jzzTcRGxtb7J+LiKyHh5aISDLffPMNypUrh9TUVPz1118AgGHDhqF8+fKGh97atWsREREBX19flC9fHh9++CGuXLlitLxu3brhjTfewKeffoo5c+YYQow5o0aNwscff4yIiAjExsbijz/+KJkPSUQlikGGiCRx6NAhzJ07F1u3bkV4eDgGDx4MIQQ++ugjnDhxwvAAgMOHD6N3795o3749tm7diuPHj+ODDz7Ao0ePjJb54MEDHDt2DEqlEufPn8/3/d966y1cunTJcGirUaNGWLhwYUl9XCIqIQwyRGR1Dx48wIABAxATE4MWLVrg66+/RmJiIhYvXgwfHx/UqFHD8AB0oadq1ar44IMP0KhRI4SEhCAtLc1kue+++y7s7Oywfft2LFiwAHv37s23jsDAQAwbNgwbNmzAu+++i6VLl5bI5yWiksMgQ0RWN2nSJAgh8OmnnwIAqlWrhjlz5uC9997D5cuXTaYPCQnBlStXsGbNGly8eBELFizAxo0bjab56aef8M0332DVqlV47bXXMH78ePTv3x+3b982W8OYMWOwY8cOpKamIikpCfv27UPt2rWL/bMSUcniYF8isqr9+/ejVatW+PnnnxEZGWn0Wtu2bfHkyRPs3r0bCoXC6LX33nsP33zzDXJzc9GhQwf861//wtSpU3Hnzh3cvHkTYWFhGD16NCZNmgQAePz4MRo3bozg4GCsXbvWZLDvyJEjsX37dvz1119wd3dHVFQU5s6dCy8vL6v1BRE9PwYZIiIiki0eWiIiIiLZYpAhIiIi2WKQISIiItlikCEiIiLZYpAhIiIi2WKQISIiItlikCEiIiLZYpAhIiIi2WKQISIiItlikCEiIiLZYpAhIiIi2WKQISIiItn6P+RmVQmXFqQGAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "\n", + "xs = [point[0] for point in points_array]\n", + "ys = [point[1] for point in points_array]\n", + "\n", + "\n", + "plt.scatter(xs, ys)\n", + "\n", + "\n", + "plt.scatter(x, y, color='red', label='First location')\n", + "\n", + "for point in decrypted_res:\n", + " plt.scatter(point[0], point[1], marker='o', facecolor='none', edgecolor='red', s=200)\n", + "\n", + "\n", + "plt.xlabel('X-axis')\n", + "plt.ylabel('Y-axis')\n", + "plt.title('Input point (colored) and its closest neighbors (circled)')\n", + "plt.legend()\n", + "\n", + "plt.show()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "zama", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.18" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +}