diff --git a/.gitleaksignore b/.gitleaksignore index 62ac2e2360..98411ee9e9 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -8,3 +8,4 @@ a99389ee01cbb972e46a892d3d0e9c7f8ee23f59:use_case_examples/training/analyze.ipyn a99389ee01cbb972e46a892d3d0e9c7f8ee23f59:use_case_examples/training/analyze.ipynb:aws-access-token:18379 f41de03048a9ed27946b875e81b34138bb4bb17b:use_case_examples/training/analyze.ipynb:aws-access-token:6404 e2904473898ddd325f245f4faca526a0e9520f49:builders/Dockerfile.zamalang-env:generic-api-key:5 +7d5e885816f1f1e432dd94da38c5c8267292056a:docs/advanced_examples/XGBRegressor.ipynb:aws-access-token:1026 diff --git a/deps_licenses/licenses_mac_silicon_user.txt b/deps_licenses/licenses_mac_silicon_user.txt index db194acd7e..36720816ac 100644 --- a/deps_licenses/licenses_mac_silicon_user.txt +++ b/deps_licenses/licenses_mac_silicon_user.txt @@ -1,28 +1,28 @@ Name, Version, License PyYAML, 6.0.1, MIT License brevitas, 0.8.0, UNKNOWN -certifi, 2024.2.2, Mozilla Public License 2.0 (MPL 2.0) +certifi, 2023.7.22, Mozilla Public License 2.0 (MPL 2.0) charset-normalizer, 3.3.2, MIT License coloredlogs, 15.0.1, MIT License concrete-python, 2024.4.19, BSD-3-Clause dependencies, 2.0.1, BSD License dill, 0.3.8, BSD License -filelock, 3.14.0, The Unlicense (Unlicense) +filelock, 3.13.4, The Unlicense (Unlicense) flatbuffers, 24.3.25, Apache Software License fsspec, 2024.3.1, BSD License huggingface-hub, 0.22.2, Apache Software License humanfriendly, 10.0, MIT License -hummingbird-ml, 0.4.11, MIT License +hummingbird-ml, 0.4.8, MIT License idna, 3.7, BSD License importlib_resources, 6.4.0, Apache Software License joblib, 1.4.0, BSD License jsonpickle, 3.0.4, BSD License mpmath, 1.3.0, BSD License networkx, 3.1, BSD License -numpy, 1.24.3, BSD License -onnx, 1.16.0, Apache License v2.0 +numpy, 1.23.5, BSD License +onnx, 1.15.0, Apache License v2.0 onnxconverter-common, 1.13.0, MIT License -onnxmltools, 1.12.0, Apache Software License +onnxmltools, 1.11.0, Apache Software License onnxoptimizer, 0.3.13, Apache License v2.0 onnxruntime, 1.17.3, MIT License packaging, 24.0, Apache Software License; BSD License @@ -32,19 +32,20 @@ psutil, 5.9.8, BSD License python-dateutil, 2.9.0.post0, Apache Software License; BSD License pytz, 2024.1, MIT License requests, 2.31.0, Apache Software License -scikit-learn, 1.3.2, BSD License +scikit-learn, 1.1.3, BSD License scipy, 1.10.1, BSD License six, 1.16.0, MIT License -skl2onnx, 1.16.0, Apache Software License +skl2onnx, 1.12, Apache Software License skops, 0.5.0, MIT skorch, 0.11.0, new BSD 3-Clause sympy, 1.12, BSD License tabulate, 0.8.10, MIT License -threadpoolctl, 3.5.0, BSD License +threadpoolctl, 3.4.0, BSD License torch, 1.13.1, BSD License tqdm, 4.66.2, MIT License; Mozilla Public License 2.0 (MPL 2.0) typing_extensions, 4.5.0, Python Software Foundation License tzdata, 2024.1, Apache Software License urllib3, 2.2.1, MIT License -xgboost, 1.7.6, Apache Software License +xgboost, 1.6.2, Apache Software License z3-solver, 4.13.0.0, MIT License +zipp, 3.18.1, MIT License diff --git a/deps_licenses/licenses_mac_silicon_user.txt.md5 b/deps_licenses/licenses_mac_silicon_user.txt.md5 index 0aa27f999d..aaf4e6a9ed 100644 --- a/deps_licenses/licenses_mac_silicon_user.txt.md5 +++ b/deps_licenses/licenses_mac_silicon_user.txt.md5 @@ -1 +1 @@ -7be80ba54850fbc203015560c8acb9a8 +9b8316c2a6c823884676b39f52eb018a diff --git a/docs/advanced_examples/DecisionTreeClassifier.ipynb b/docs/advanced_examples/DecisionTreeClassifier.ipynb index b3f59fb015..8aca4f5c86 100644 --- a/docs/advanced_examples/DecisionTreeClassifier.ipynb +++ b/docs/advanced_examples/DecisionTreeClassifier.ipynb @@ -35,8 +35,12 @@ "import time\n", "\n", "import numpy\n", - "from sklearn.datasets import fetch_openml\n", - "from sklearn.model_selection import train_test_split\n", + "from sklearn.datasets import fetch_openml, make_classification\n", + "from sklearn.metrics import accuracy_score, average_precision_score, confusion_matrix\n", + "\n", + "# Find best hyper parameters with cross validation\n", + "from sklearn.model_selection import GridSearchCV, train_test_split\n", + "from tqdm.auto import tqdm\n", "\n", "features, classes = fetch_openml(data_id=44, as_frame=False, cache=True, return_X_y=True)\n", "classes = classes.astype(numpy.int64)\n", @@ -65,16 +69,29 @@ "name": "stdout", "output_type": "stream", "text": [ +<<<<<<< HEAD "Best hyper parameters: {'max_depth': None, 'max_features': None, 'min_samples_leaf': 10, 'min_samples_split': 100}\n", "Best score: 0.9302470037668247\n" +======= + "Best hyper parameters: {'max_depth': None, 'max_features': 'sqrt', 'min_samples_leaf': 10, 'min_samples_split': 10}\n", + "Best score: 0.9300392741953442\n", + "CPU times: user 3 s, sys: 653 ms, total: 3.65 s\n", + "Wall time: 24.9 s\n" +>>>>>>> 86507b52 (feat: support `from_sklearn` for trees) ] } ], "source": [ + "from sklearn.ensemble import RandomForestClassifier as SklearnRandomForestClassifier\n", + "\n", + "%%time\n", "# Find best hyper parameters with cross validation\n", - "from sklearn.model_selection import GridSearchCV\n", + "from sklearn.tree import DecisionTreeClassifier as SklearnDecisionTreeClassifier\n", + "from xgboost.sklearn import XGBClassifier as SklearnXGBClassifier\n", "\n", "from concrete.ml.sklearn import DecisionTreeClassifier as ConcreteDecisionTreeClassifier\n", + "from concrete.ml.sklearn import RandomForestClassifier as ConcreteRandomForestClassifier\n", + "from concrete.ml.sklearn import XGBClassifier as ConcreteXGBClassifier\n", "\n", "# List of hyper parameters to tune\n", "param_grid = {\n", @@ -132,20 +149,21 @@ "name": "stdout", "output_type": "stream", "text": [ - "Sklearn average precision score: 0.95\n", - "Concrete average precision score: 0.97\n" + "Sklearn average precision score: 0.94\n", + "Concrete average precision score: 0.94\n" ] } ], "source": [ "# Compute average precision on test\n", - "from sklearn.metrics import average_precision_score\n", "\n", "# pylint: disable=no-member\n", "y_pred_concrete = model.predict_proba(x_test)[:, 1]\n", "y_pred_sklearn = sklearn_model.predict_proba(x_test)[:, 1]\n", + "\n", "concrete_average_precision = average_precision_score(y_test, y_pred_concrete)\n", "sklearn_average_precision = average_precision_score(y_test, y_pred_sklearn)\n", + "\n", "print(f\"Sklearn average precision score: {sklearn_average_precision:0.2f}\")\n", "print(f\"Concrete average precision score: {concrete_average_precision:0.2f}\")" ] @@ -169,8 +187,8 @@ "text": [ "Number of test samples: 691\n", "Number of spams in test samples: 304\n", - "True Negative (legit mail well classified) rate: 0.9612403100775194\n", - "False Positive (legit mail classified as spam) rate: 0.03875968992248062\n", + "True Negative (legit mail well classified) rate: 0.9302325581395349\n", + "False Positive (legit mail classified as spam) rate: 0.06976744186046512\n", "False Negative (spam mail classified as legit) rate: 0.14473684210526316\n", "True Positive (spam well classified) rate: 0.8552631578947368\n" ] @@ -178,7 +196,6 @@ ], "source": [ "# Show the confusion matrix on x_test\n", - "from sklearn.metrics import confusion_matrix\n", "\n", "y_pred = model.predict(x_test)\n", "true_negative, false_positive, false_negative, true_positive = confusion_matrix(\n", @@ -247,7 +264,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Key generation time: 0.48 seconds\n" + "Key generation time: 0.82 seconds\n" ] } ], @@ -265,7 +282,7 @@ "source": [ "# Reduce the sample size for a faster total execution time\n", "FHE_SAMPLES = 10\n", - "x_test = x_test[:FHE_SAMPLES]\n", + "x_test_fhe = x_test[:FHE_SAMPLES]\n", "y_pred = y_pred[:FHE_SAMPLES]\n", "y_reference = y_test[:FHE_SAMPLES]" ] @@ -279,15 +296,15 @@ "name": "stdout", "output_type": "stream", "text": [ - "Execution time: 0.53 seconds per sample\n" + "Execution time: 3.75 seconds per sample\n" ] } ], "source": [ "# Predict in FHE for a few examples\n", "time_begin = time.time()\n", - "y_pred_fhe = model.predict(x_test, fhe=\"execute\")\n", - "print(f\"Execution time: {(time.time() - time_begin) / len(x_test):.2f} seconds per sample\")" + "y_pred_fhe = model.predict(x_test_fhe, fhe=\"execute\")\n", + "print(f\"Execution time: {(time.time() - time_begin) / len(x_test_fhe):.2f} seconds per sample\")" ] }, { @@ -300,8 +317,8 @@ "output_type": "stream", "text": [ "Ground truth: [0 0 0 1 0 1 0 0 0 0]\n", - "Prediction sklearn: [0 0 0 1 0 1 0 0 0 0]\n", - "Prediction FHE: [0 0 0 1 0 1 0 0 0 0]\n" + "Prediction sklearn: [0 0 0 0 0 0 0 1 0 0]\n", + "Prediction FHE: [0 0 0 0 0 0 0 1 0 0]\n" ] } ], @@ -332,6 +349,234 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Importing from scikit-learn" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "features, classes = fetch_openml(data_id=44, as_frame=False, cache=True, return_X_y=True)\n", + "classes = classes.astype(numpy.int64)\n", + "features, classes = make_classification(\n", + " **{\"n_samples\": 1000, \"n_features\": 10, \"n_classes\": 4, \"n_informative\": 10, \"n_redundant\": 0}\n", + ")\n", + "\n", + "\n", + "x_train, x_test, y_train, y_test = train_test_split(\n", + " features,\n", + " classes,\n", + " test_size=0.15,\n", + " random_state=42,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e7c1622bb3574144b642a80cb6ef1e9b", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + " 0%| | 0/14 [00:00" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "cv = False\n", + "\n", + "for model_name in [\"tree\", \"rf\", \"xgb\"]:\n", + " if model_name == \"tree\":\n", + " ClearModel = SklearnDecisionTreeClassifier\n", + " FheModel = ConcreteDecisionTreeClassifier\n", + "\n", + " param_grid = {\n", + " \"max_features\": [None, \"auto\", \"sqrt\", \"log2\"],\n", + " \"min_samples_leaf\": [1, 10, 100],\n", + " \"min_samples_split\": [2, 10, 100],\n", + " \"max_depth\": [None, 2, 4, 6, 8],\n", + " }\n", + " elif model_name == \"rf\":\n", + " ClearModel = SklearnRandomForestClassifier\n", + " FheModel = ConcreteRandomForestClassifier\n", + "\n", + " param_grid = {\n", + " \"max_features\": [None, \"auto\", \"sqrt\", \"log2\"],\n", + " \"min_samples_leaf\": [1, 10, 100],\n", + " \"min_samples_split\": [2, 10, 100],\n", + " \"max_depth\": [None, 2, 4, 6, 8],\n", + " \"n_estimators\": [2, 8, 16],\n", + " }\n", + " elif model_name == \"xgb\":\n", + " ClearModel = SklearnXGBClassifier\n", + " FheModel = ConcreteXGBClassifier\n", + "\n", + " param_grid = {\n", + " # \"max_features\": [None, \"auto\", \"sqrt\", \"log2\"],\n", + " \"min_samples_leaf\": [1, 10, 100],\n", + " \"min_samples_split\": [2, 10, 100],\n", + " #\n", + " \"max_depth\": [None, 2, 4, 6, 8],\n", + " \"n_estimators\": [2, 8, 16],\n", + " }\n", + " assert FheModel.__name__ == ClearModel.__name__\n", + "\n", + " # List of hyper parameters to tune\n", + " if cv:\n", + "\n", + " grid_search = GridSearchCV(\n", + " ClearModel(),\n", + " param_grid,\n", + " cv=10,\n", + " scoring=\"average_precision\",\n", + " error_score=\"raise\",\n", + " n_jobs=-1,\n", + " )\n", + "\n", + " gs_results = grid_search.fit(x_train, y_train)\n", + " print(\"Best hyper parameters:\", gs_results.best_params_)\n", + " print(\"Best score:\", gs_results.best_score_)\n", + "\n", + " # Build the model with best hyper parameters\n", + " sk_model = ClearModel(\n", + " **gs_results.best_params_,\n", + " )\n", + " else:\n", + " sk_model = ClearModel()\n", + "\n", + " sk_model.fit(x_train, y_train)\n", + "\n", + " # Compute average precision on test\n", + "\n", + " data = []\n", + " verbose = False\n", + "\n", + " for n_bits in tqdm(range(2, 16)):\n", + "\n", + " model_from_data = FheModel.from_sklearn_model(sk_model, x_train, n_bits=n_bits)\n", + " model_from_thresholds = FheModel.from_sklearn_model(sk_model, n_bits=n_bits)\n", + "\n", + " # pylint: disable=no-member\n", + " y_pred_concrete_from_data = model_from_data.predict_proba(x_test).argmax(axis=1)\n", + " y_pred_concrete_from_thresholds = model_from_thresholds.predict_proba(x_test).argmax(axis=1)\n", + " y_pred_sklearn = sk_model.predict_proba(x_test).argmax(axis=1)\n", + "\n", + " concrete_from_data_average_precision = accuracy_score(y_test, y_pred_concrete_from_data)\n", + " concrete_from_thresholds_average_precision = accuracy_score(\n", + " y_test, y_pred_concrete_from_thresholds\n", + " )\n", + " sklearn_average_precision = accuracy_score(y_test, y_pred_sklearn)\n", + "\n", + " data.append(\n", + " {\n", + " \"n_bits\": n_bits,\n", + " \"sklearn\": sklearn_average_precision,\n", + " \"from_thresholds\": concrete_from_thresholds_average_precision,\n", + " \"from_data\": concrete_from_data_average_precision,\n", + " }\n", + " )\n", + " if verbose:\n", + " print(f\"Sklearn average precision score: {sklearn_average_precision:0.2f}\")\n", + " print(\n", + " \"Concrete (from data) average precision score: \"\n", + " f\"{concrete_from_data_average_precision:0.2f}\"\n", + " )\n", + " print(\n", + " \"Concrete (from thresholds) average precision score: \"\n", + " f\"{concrete_from_thresholds_average_precision:0.2f}\"\n", + " )\n", + "\n", + " import matplotlib.pyplot as plt\n", + " import pandas as pd\n", + "\n", + " data = pd.DataFrame(data)\n", + "\n", + " fig, ax = plt.subplots()\n", + " for label in [\"sklearn\", \"from_thresholds\", \"from_data\"]:\n", + " ax.plot(data[\"n_bits\"], data[label], label=label)\n", + " ax.set_ylabel(\"accuracy\")\n", + " ax.set_xlabel(\"n-bits\")\n", + " ax.legend()\n", + " ax.set_title(f\"Accuracy per n-bits importing a {FheModel.__name__}\")\n", + " fig.show()\n", + "\n", + " data[\"sklearn - data\"] = data[\"sklearn\"] - data[\"from_data\"]\n", + " data[\"sklearn - threshold\"] = data[\"sklearn\"] - data[\"from_thresholds\"]" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -355,5 +600,5 @@ } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/docs/advanced_examples/XGBClassifier.ipynb b/docs/advanced_examples/XGBClassifier.ipynb index 5d9607028d..69fc290f9f 100644 --- a/docs/advanced_examples/XGBClassifier.ipynb +++ b/docs/advanced_examples/XGBClassifier.ipynb @@ -587,5 +587,5 @@ } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/poetry.lock b/poetry.lock index 296f3f21e4..0dbd3a8a3f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "absl-py" @@ -4720,38 +4720,6 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] -[[package]] -name = "pytest-json-report" -version = "1.5.0" -description = "A pytest plugin to report test results as JSON files" -optional = false -python-versions = "*" -files = [ - {file = "pytest-json-report-1.5.0.tar.gz", hash = "sha256:2dde3c647851a19b5f3700729e8310a6e66efb2077d674f27ddea3d34dc615de"}, - {file = "pytest_json_report-1.5.0-py3-none-any.whl", hash = "sha256:9897b68c910b12a2e48dd849f9a284b2c79a732a8a9cb398452ddd23d3c8c325"}, -] - -[package.dependencies] -pytest = ">=3.8.0" -pytest-metadata = "*" - -[[package]] -name = "pytest-metadata" -version = "3.1.1" -description = "pytest plugin for test session metadata" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b"}, - {file = "pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[package.extras] -test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"] - [[package]] name = "pytest-randomly" version = "3.15.0" @@ -4781,6 +4749,21 @@ files = [ [package.dependencies] pytest = "*" +[[package]] +name = "pytest-subtests" +version = "0.11.0" +description = "unittest subTest() support and subtests fixture" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-subtests-0.11.0.tar.gz", hash = "sha256:51865c88457545f51fb72011942f0a3c6901ee9e24cbfb6d1b9dc1348bafbe37"}, + {file = "pytest_subtests-0.11.0-py3-none-any.whl", hash = "sha256:453389984952eec85ab0ce0c4f026337153df79587048271c7fd0f49119c07e4"}, +] + +[package.dependencies] +attrs = ">=19.2.0" +pytest = ">=7.0" + [[package]] name = "pytest-xdist" version = "3.5.0" @@ -7310,4 +7293,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<3.11" -content-hash = "18e37472ff221c5e6f57c7a32e3d4d84ed999622ee5ec3c9900627bd9e01349a" +content-hash = "e27e0b729449f72af645776b4002a9e8fba9bfd49a9282bc87104fecc1e410fe" diff --git a/pyproject.toml b/pyproject.toml index e35631fcf4..834cff6acd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,18 +64,18 @@ pylint = "^2.13.0" # FIXME: https://github.com/zama-ai/concrete-ml-internal/issues/2541 pytest = "7.4.1" pytest-cov = "^4.1.0" +pytest-xdist = "^3.3.1" +pytest-randomly = "^3.11.0" +pytest-repeat = "^0.9.1" +pytest-subtests = "^0.11.0" pytest_codeblocks = "^0.14.0" mypy = "^1.8.0" pydocstyle = "^6.1.1" python-semantic-release = "^7.27.0" semver = "^2.13.0" tomlkit = "^0.7.0" -pytest-json-report = "^1.5.0" -pytest-xdist = "^3.3.1" -pytest-randomly = "^3.11.0" nbmake = "^1.3.0" pygments-style-tomorrow = "^1.0.0" -pytest-repeat = "^0.9.1" mdformat = "^0.7.14" mdformat_myst = "^0.1.4" mdformat-toc = "^0.3.0" diff --git a/src/concrete/ml/onnx/onnx_impl_utils.py b/src/concrete/ml/onnx/onnx_impl_utils.py index 158f513ae4..6a04fe6c14 100644 --- a/src/concrete/ml/onnx/onnx_impl_utils.py +++ b/src/concrete/ml/onnx/onnx_impl_utils.py @@ -5,7 +5,7 @@ import numpy from concrete.fhe import conv as fhe_conv from concrete.fhe import ones as fhe_ones -from concrete.fhe import round_bit_pattern +from concrete.fhe import truncate_bit_pattern from concrete.fhe.tracing import Tracer from ..common.debugging import assert_true @@ -265,9 +265,16 @@ def rounded_comparison( # Workaround: in this context, `round_bit_pattern` is used as a truncate operation. # Consequently, we subtract a term, called `half` that will subsequently be re-added during the # `round_bit_pattern` process. - half = 1 << (lsbs_to_remove - 1) + # half = 1 << (lsbs_to_remove - 1) # To determine if 'x' 'operation' 'y' (operation being <, >, >=, <=), we evaluate 'x - y' - rounded_subtraction = round_bit_pattern((x - y) - half, lsbs_to_remove=lsbs_to_remove) + # We cast to int because if half is too high the result might be float + # intermediate = ((x - y) - half) + # intermediate_as_int = intermediate.astype(numpy.int64) + # + # if not isinstance(intermediate, Tracer): + # assert (intermediate == intermediate_as_int).all() + + rounded_subtraction = truncate_bit_pattern(x - y, lsbs_to_remove=lsbs_to_remove) return (operation(rounded_subtraction),) diff --git a/src/concrete/ml/quantization/quantizers.py b/src/concrete/ml/quantization/quantizers.py index 2807645434..c4f682f9d7 100644 --- a/src/concrete/ml/quantization/quantizers.py +++ b/src/concrete/ml/quantization/quantizers.py @@ -797,7 +797,7 @@ def dequant(self, qvalues: numpy.ndarray) -> Union[numpy.ndarray, Tracer]: values = self.scale * (qvalues - numpy.asarray(self.zero_point, dtype=numpy.float64)) - assert isinstance(values, (numpy.ndarray, Tracer)) + assert isinstance(values, (float, int, numpy.ndarray, Tracer)) return values diff --git a/src/concrete/ml/sklearn/base.py b/src/concrete/ml/sklearn/base.py index 125e3f09a0..93bbda10e0 100644 --- a/src/concrete/ml/sklearn/base.py +++ b/src/concrete/ml/sklearn/base.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import inspect import os import tempfile @@ -13,9 +14,12 @@ from abc import ABC, abstractmethod from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Set, TextIO, Type, Union +from typing import Any, Callable, Dict, List, Optional, Set, TextIO, Tuple, Type, Union import brevitas.nn as qnn + +# pylint: disable-next=ungrouped-imports +import concrete.fhe as cp import numpy import onnx import sklearn @@ -27,12 +31,11 @@ from concrete.fhe.compilation.compiler import Compiler from concrete.fhe.compilation.configuration import Configuration from concrete.fhe.dtypes.integer import Integer +from onnx import numpy_helper from sklearn.base import clone from sklearn.linear_model import LinearRegression, LogisticRegression from sklearn.utils.validation import check_is_fitted - -# pylint: disable-next=ungrouped-imports -from concrete import fhe as cp +from xgboost.sklearn import XGBModel from ..common.check_inputs import check_array_and_assert, check_X_y_and_assert_multi_output from ..common.debugging.custom_assert import assert_true @@ -65,7 +68,15 @@ ) from ..torch import NumpyModule from .qnn_module import SparseQuantNeuralNetwork -from .tree_to_numpy import tree_to_numpy +from .tree_to_numpy import ( + _compute_lsb_to_remove_for_trees, + get_equivalent_numpy_forward_from_onnx_tree, + get_onnx_model, + is_regressor_or_partial_regressor, + preprocess_tree_predictions, + tree_onnx_graph_preprocessing, + tree_to_numpy, +) # Disable pylint to import Hummingbird while ignoring the warnings # pylint: disable=wrong-import-position,wrong-import-order @@ -1328,6 +1339,279 @@ def __init__(self, n_bits: Union[int, Dict[str, int]]): BaseEstimator.__init__(self) + # TODO: FIX EXACT PREDICTION WITH HIGH BIT WIDTH + # pylint: disable=too-many-locals,too-many-statements,too-many-branches + @classmethod + def from_sklearn_model( + cls, + sklearn_model: sklearn.base.BaseEstimator, + X: Optional[numpy.ndarray] = None, + n_bits: int = 8, + ): + """Build a FHE-compliant model using a fitted scikit-learn model. + + Args: + sklearn_model (sklearn.base.BaseEstimator): The fitted scikit-learn model to convert. + X (Optional[Data]): A representative set of input values used for computing quantization + parameters, as a Numpy array, Torch tensor, Pandas DataFrame or List. This is + usually the training data-set or a sub-set of it. + n_bits (int): Number of bits to quantize the model. If an int is passed + for n_bits, the value will be used for quantizing inputs and weights. If a dict is + passed, then it should contain "op_inputs" and "op_weights" as keys with + corresponding number of quantization bits so that: + - op_inputs : number of bits to quantize the input values + - op_weights: number of bits to quantize the learned parameters + Default to 8. + + Returns: + The FHE-compliant fitted model. + """ + # Check that sklearn_model is a proper fitted scikit-learn model + check_is_fitted(sklearn_model) + + # Extract scikit-learn's initialization parameters + init_params = sklearn_model.get_params() + + # Instantiate the Concrete ML model and update initialization parameters + # This update is necessary as we currently store scikit-learn attributes in Concrete ML + # classes during initialization (for example: link or power attributes in GLMs) + # Without it, these attributes will have default values instead of the ones used by the + # scikit-learn models + # This should be fixed once Concrete ML models initialize their underlying scikit-learn + # models during initialization + # FIXME: https://github.com/zama-ai/concrete-ml-internal/issues/3373 + # Needed for XGB + + cls_signature = inspect.signature(cls) + init_params_keys = list(init_params.keys()) + for key in init_params_keys: + if key not in cls_signature.parameters: + init_params.pop(key) + model = cls(n_bits=n_bits, **init_params) + model._is_fitted = True + + # Update the underlying scikit-learn model with the given fitted one + model.sklearn_model = copy.deepcopy(sklearn_model) + + # Get the onnx model, all operations needed to load it properly will be done on it. + n_features = model.n_features_in_ + dummy_input = numpy.zeros((1, n_features)) + framework = "xgboost" if isinstance(sklearn_model, XGBModel) else "sklearn" + onnx_model = get_onnx_model( + model=sklearn_model, + x=dummy_input, + framework=framework, + ) + + # Get feature -> thresholds mappings and threshold values + weight_1 = numpy.empty((0,)) + bias_1 = numpy.empty((0,)) + bias_1_index = -1 + bias_1_name = "" + + for initializer_index, initializer in enumerate(onnx_model.graph.initializer): + init_tensor = numpy_helper.to_array(initializer) + if "weight_1" in initializer.name: + # weight_1 is the feature node selector + weight_1 = init_tensor.copy() + elif "bias_1" in initializer.name: + # bias _1 is the threshold tensor + bias_1 = init_tensor.copy() + bias_1_index = initializer_index + bias_1_name = initializer.name + + assert bias_1_name + assert bias_1_index >= 0 + assert weight_1.size != 0 + assert bias_1.size != 0 + + # Compute input/threshold quantizers + input_quantizers: List[UniformQuantizer] = [] + + # Quantization of each feature in X + for feature_index in range(n_features): + + # Get all thresholds for a given feature + threshold_for_feature: numpy.ndarray = bias_1[weight_1[:, feature_index] == 1][:, 0] + # Sorting threshold values makes things easier afterwards + threshold_for_feature.sort() + + # All unique threshold values + unique_threshold_for_feature_sorted = numpy.unique(threshold_for_feature) + unique_threshold_for_feature_sorted.sort() + num_unique_thresholds = len(unique_threshold_for_feature_sorted) + + if num_unique_thresholds >= 1: + max_threshold_value = unique_threshold_for_feature_sorted.max() + min_threshold_value = unique_threshold_for_feature_sorted.min() + else: + # TODO: maybe we should pick a random value here ? + max_threshold_value = 1.0 + min_threshold_value = 0.0 + + # We compute a epsilon such that we have one quantized value on each side of the range + # This offset will either be a right or left offset according to the framework + # TODO: reconsider this + number_of_need_offset_values = 2 + if num_unique_thresholds == 0: + epsilon = 1.0 + elif num_unique_thresholds == 1: + epsilon = 1.0 + else: + epsilon = (max_threshold_value - min_threshold_value) / ( + (2**n_bits) - number_of_need_offset_values + ) + + # Input quantizers based on thresholds + # TODO: DOUBLE CHECK THIS PART + if X is None: + if num_unique_thresholds: + min_quantization_value = min_threshold_value + max_quantization_value = max_threshold_value + else: + min_quantization_value = 0 + max_quantization_value = 1.0 + + if num_unique_thresholds == 1: + # If there is only one threshold for this feature + # We want the threshold to be in the middle of a quantization bin + min_quantization_value -= epsilon + max_quantization_value += epsilon + elif framework == "xgboost": + # XGBoost uses a < op so we must add a left offset + min_quantization_value -= epsilon + else: + # scikit-learn uses =< op so we must add a right offset + max_quantization_value += epsilon + + # Quantizer based on data + else: + min_quantization_value = X[:, feature_index].min() + max_quantization_value = X[:, feature_index].max() + + min_quantization_value = float(min_quantization_value) + max_quantization_value = float(max_quantization_value) + input_quantizer = QuantizedArray( + n_bits=n_bits, + values=numpy.array([min_quantization_value, max_quantization_value]), + ).quantizer + # TODO: Assert that there is one and only one bit-value above and below the threshold + input_quantizers.append(input_quantizer) + + # Convert thresholds to their quantized equivalent + quantized_thresholds_array = numpy.empty(bias_1.shape, dtype=numpy.int64) + dequantized_thresholds_array = numpy.empty(bias_1.shape, dtype=numpy.float64) + + for threshold_index, threshold_value in enumerate(bias_1[:, 0]): + feature_index = int(weight_1[threshold_index, :].argmax()) + quantized_threshold_value = ( + input_quantizers[feature_index].quant(threshold_value).astype(numpy.int64) + ) + dequantized_threshold_value = input_quantizers[feature_index].dequant( + quantized_threshold_value + ) + quantized_thresholds_array[threshold_index, 0] = quantized_threshold_value + dequantized_thresholds_array[threshold_index, 0] = dequantized_threshold_value + + # TODO: debug + if n_bits > 20: + diff = dequantized_thresholds_array - bias_1 + max_diff = numpy.abs(diff).max() + if max_diff > 1e-4: + print("ERROR") + print(max_diff) + + onnx_model.graph.initializer[bias_1_index].CopyFrom( + numpy_helper.from_array( + quantized_thresholds_array, + bias_1_name, + ) + ) + + # Tree values pre-processing + # i.e., mainly predictions quantization + # but also rounding the threshold such that they are now integers + model._set_post_processing_params() + + # Get the expected number of ONNX outputs in the sklearn model. + expected_number_of_outputs = 1 if is_regressor_or_partial_regressor(model) else 2 + + # Modify the graph inplace to keep only the parts that are of interest to us + tree_onnx_graph_preprocessing(onnx_model, framework, expected_number_of_outputs) + + # Get the preprocessed tree predictions to replace the current + # (non-quantized) values in the onnx_model. + q_y = None + for initializer_index, initializer in enumerate(onnx_model.graph.initializer): + init_tensor = numpy_helper.to_array(initializer) + if "weight_3" in initializer.name: + # weight_3 is the prediction tensor + # Here we quantize it + q_y = preprocess_tree_predictions(init_tensor, n_bits) + init_tensor_as_int = q_y.qvalues.astype(numpy.int64) + else: + init_tensor_as_int = init_tensor.astype(numpy.int64) + assert ( + isinstance(init_tensor_as_int, numpy.ndarray) + and init_tensor_as_int.dtype == numpy.int64 + ) + new_initializer = numpy_helper.from_array(init_tensor_as_int, initializer.name) + onnx_model.graph.initializer[initializer_index].CopyFrom(new_initializer) + + # Convert the tree inference with Numpy operators + enable_rounding = bool(int(os.environ.get("TREES_USE_ROUNDING", 1))) + + if not enable_rounding: + warnings.simplefilter("always") + warnings.warn( + "Using Concrete tree-based models without the `rounding feature` is deprecated. " + "Consider setting 'use_rounding' to `True` for making the FHE inference faster " + "and key generation.", + category=DeprecationWarning, + stacklevel=2, + ) + + lsbs_to_remove_for_trees: Optional[Tuple[int, int]] = None + + model.input_quantizers = input_quantizers + assert q_y is not None + model.output_quantizers = [q_y.quantizer] + + if enable_rounding: + # Quantize some data + if X is None: + assert isinstance(n_features, int) + calibration_set_size = 100_000 + q_X = numpy.empty((calibration_set_size, n_features), dtype=numpy.int64) + for feature_index in range(n_features): + min_value = input_quantizers[feature_index].rmin + assert min_value is not None + max_value = input_quantizers[feature_index].rmax + assert max_value is not None + q_X[:, feature_index] = ( + input_quantizers[feature_index] + .quant(numpy.linspace(min_value, max_value, calibration_set_size)) + .astype(numpy.int64) + ) + q_X = numpy.random.permutation(q_X) + else: + q_X = model.quantize_input(X).astype(numpy.int64) + + # Compute for tree-based models the LSB to remove in stage 1 and stage 2 + # First LSB refers to Less or LessOrEqual comparisons + # Second LSB refers to Equal comparison + assert q_X.dtype == numpy.int64 + lsbs_to_remove_for_trees = _compute_lsb_to_remove_for_trees(onnx_model, q_X) + + # mypy + assert len(lsbs_to_remove_for_trees) == 2 + + model._tree_inference, model.onnx_model_ = get_equivalent_numpy_forward_from_onnx_tree( + onnx_model, lsbs_to_remove_for_trees=lsbs_to_remove_for_trees + ) + + return model + def fit(self, X: Data, y: Target, **fit_parameters): # Reset for double fit self._is_fitted = False @@ -1560,6 +1844,8 @@ def from_sklearn_model( Returns: The FHE-compliant fitted model. """ + # For now we don't use X for quantization, only the thresholds + # We could support import with data as quantizer too # Check that sklearn_model is a proper fitted scikit-learn model check_is_fitted(sklearn_model) @@ -1660,7 +1946,7 @@ def _quantize_model(self, X): weights = self.sklearn_model.coef_.T q_weights = QuantizedArray( n_bits=n_bits["op_weights"], - values=numpy.expand_dims(weights, axis=1) if len(weights.shape) == 1 else weights, + values=(numpy.expand_dims(weights, axis=1) if len(weights.shape) == 1 else weights), options=weight_options, ) self._q_weights = q_weights.qvalues @@ -1988,7 +2274,7 @@ def fit(self, X: Data, y: Target, **fit_parameters): # We assume that the inputs have the same distribution as the _fit_X q_fit_X = QuantizedArray( n_bits=self.n_bits, - values=numpy.expand_dims(_fit_X, axis=1) if len(_fit_X.shape) == 1 else _fit_X, + values=(numpy.expand_dims(_fit_X, axis=1) if len(_fit_X.shape) == 1 else _fit_X), options=input_options, ) self._q_fit_X = q_fit_X.qvalues diff --git a/src/concrete/ml/sklearn/tree_to_numpy.py b/src/concrete/ml/sklearn/tree_to_numpy.py index 14f4ab732c..a124d64444 100644 --- a/src/concrete/ml/sklearn/tree_to_numpy.py +++ b/src/concrete/ml/sklearn/tree_to_numpy.py @@ -6,6 +6,7 @@ import numpy import onnx +import sklearn from onnx import numpy_helper from ..common.debugging.custom_assert import assert_true @@ -44,11 +45,11 @@ MIN_CIRCUIT_THRESHOLD_FOR_TREES = 4 -def get_onnx_model(model: Callable, x: numpy.ndarray, framework: str) -> onnx.ModelProto: +def get_onnx_model(model, x: numpy.ndarray, framework: str) -> onnx.ModelProto: """Create ONNX model with Hummingbird convert method. Args: - model (Callable): The tree model to convert. + model: The tree model to convert. x (numpy.ndarray): Dataset used to trace the tree inference and convert the model to ONNX. framework (str): The framework from which the ONNX model is generated. (options: 'xgboost', 'sklearn') @@ -328,7 +329,7 @@ def tree_values_preprocessing( # pylint: disable=too-many-locals def tree_to_numpy( - model: Callable, + model: sklearn.base.BaseEstimator, x: numpy.ndarray, framework: str, use_rounding: bool = True, @@ -411,6 +412,9 @@ def _compute_lsb_to_remove_for_trees( Returns: Tuple[int, int]: the number of LSB to remove for level 1 and level 2 + + Raises: + ValueError: if comparison function ('Less' or 'LessOrEqual') cannot be determined. """ def get_bitwidth(array: numpy.ndarray) -> int: @@ -502,6 +506,9 @@ def get_lsbs_to_remove_for_trees(array: numpy.ndarray) -> int: stage_1 = bias_1 - (q_x @ mat_1.transpose(0, 2, 1)) matrix_q = stage_1 >= 0 + else: + raise ValueError("Couldn't see if the comparison is 'Less' or 'LessOrEqual'") + lsbs_to_remove_for_trees_stage_1 = get_lsbs_to_remove_for_trees(stage_1) # If operator is `==`, np.equal(x, y) is equivalent to: diff --git a/tests/sklearn/test_sklearn_models.py b/tests/sklearn/test_sklearn_models.py index b9cb33e84a..5f4ebc78f6 100644 --- a/tests/sklearn/test_sklearn_models.py +++ b/tests/sklearn/test_sklearn_models.py @@ -39,7 +39,13 @@ import torch from sklearn.decomposition import PCA from sklearn.exceptions import ConvergenceWarning, UndefinedMetricWarning -from sklearn.metrics import make_scorer, matthews_corrcoef, top_k_accuracy_score +from sklearn.metrics import ( + accuracy_score, + make_scorer, + matthews_corrcoef, + mean_squared_error, + top_k_accuracy_score, +) from sklearn.model_selection import GridSearchCV from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler @@ -71,6 +77,7 @@ _get_sklearn_neural_net_models, _get_sklearn_tree_models, ) +from concrete.ml.sklearn.base import BaseTreeClassifierMixin, BaseTreeEstimatorMixin # Allow multiple runs in FHE to make sure we always have the correct output N_ALLOWED_FHE_RUN = 5 @@ -679,7 +686,12 @@ def check_input_support(model_class, n_bits, default_configuration, x, y, input_ def cast_input(x, y, input_type): "Convert x and y either in Pandas, List, Numpy or Torch type." - assert input_type in ["pandas", "torch", "list", "numpy"], "Not a valid type casting" + assert input_type in [ + "pandas", + "torch", + "list", + "numpy", + ], "Not a valid type casting" if input_type.lower() == "pandas": # Turn into Pandas @@ -817,7 +829,12 @@ def check_grid_search(model_class, x, y, scoring): pytest.skip("Skipping predict_proba for KNN, doesn't work for now") _ = GridSearchCV( - model_class(), param_grid, cv=2, scoring=scoring, error_score="raise", n_jobs=1 + model_class(), + param_grid, + cv=2, + scoring=scoring, + error_score="raise", + n_jobs=1, ).fit(x, y) @@ -1120,6 +1137,209 @@ def check_exposition_structural_methods_decision_trees(model, x, y): ) +# Add a test to match fp32 -> quant -> dequant weights at the ONNX level +# in the high bit width setting +# Some snippet to do this: +# if n_bits > 17: +# diff = init_tensor - init_tensor_as_int +# max_diff = numpy.abs(diff).max() +# if max_diff > 1e-3: +# raise ValueError(f"{max_diff=} > 1e-4") + + +# pylint: disable-next=too-many-locals,too-many-statements +# TODO: make this pass with rounding (high-bit-width create overflow) +@pytest.mark.parametrize("model_class, parameters", get_sklearn_tree_models_and_datasets()) +@pytest.mark.parametrize("use_rounding", [False, True]) +def test_load_fitted_sklearn_tree_models( + subtests, + model_class, + parameters, + use_rounding, + load_data, + is_weekly_option, + verbose=True, +): + """Test `from_sklearn_model` functionnality of tree-based models.""" + + numpy.random.seed(0) + os.environ["TREES_USE_ROUNDING"] = str(int(use_rounding)) + + x, y = get_dataset( + model_class, parameters, min(N_BITS_REGULAR_BUILDS), load_data, is_weekly_option + ) + + if verbose: + print("Run check_load_pre_trained_sklearn_models") + + assert issubclass(model_class, BaseTreeEstimatorMixin) + concrete_model = instantiate_model_generic(model_class, n_bits=min(N_BITS_REGULAR_BUILDS)) + # Fit the model and retrieve both the Concrete ML and the scikit-learn models + with warnings.catch_warnings(): + # Sometimes, we miss convergence, which is not a problem for our test + warnings.simplefilter("ignore", category=ConvergenceWarning) + concrete_model, sklearn_model = concrete_model.fit_benchmark(x, y) + + # This step is needed in order to handle partial classes + model_class = get_model_class(model_class) + max_n_bits = 18 + reasonable_n_bits = 8 + + # TODO: add normal bit-width comparison + if isinstance(concrete_model, BaseTreeClassifierMixin): + for n_bits, cml_tolerance, sklearn_tolerance in [ + (max_n_bits, 1e-1, 1e-7), + (reasonable_n_bits, 6e-2, 6e-2), + ]: + # Load a Concrete ML model from the fitted scikit-learn one + loaded_from_threshold = model_class.from_sklearn_model( + sklearn_model, + X=None, + n_bits=n_bits, + ) + + loaded_from_data = model_class.from_sklearn_model( + sklearn_model, + X=x, + n_bits=n_bits, + ) + + # Compile both the initial Concrete ML model and the loaded one + concrete_model.compile(x) + mode = "disable" + if n_bits <= 8: + loaded_from_threshold.compile(x) + loaded_from_data.compile(x) + + # Compute and compare the predictions from both models + # Classifiers + + # Predict with all models + sklearn_pred = sklearn_model.predict_proba(x) + cml_y_pred = concrete_model.predict_proba( + x, + fhe=mode, + ) + cml_threshold_y_pred = loaded_from_threshold.predict_proba( + x, + fhe=mode, + ) + cml_data_y_pred = loaded_from_data.predict_proba( + x, + fhe=mode, + ) + + # Compute accuracy + sklearn_accuracy = accuracy_score(sklearn_pred.argmax(axis=1), y) + cml_accuracy = accuracy_score(cml_y_pred.argmax(axis=1), y) + loaded_accuracy_from_threshold_accuracy = accuracy_score( + cml_threshold_y_pred.argmax(axis=1), y + ) + loaded_accuracy_from_data_accuracy = accuracy_score(cml_data_y_pred.argmax(axis=1), y) + + # Compare with sklearn + with subtests.test( + msg="Classifier Sklearn vs Threshold", n_bits=n_bits, tolerance=sklearn_tolerance + ): + value = numpy.abs(loaded_accuracy_from_threshold_accuracy - sklearn_accuracy) + assert ( + value < sklearn_tolerance + ), f"{loaded_accuracy_from_threshold_accuracy=} != {sklearn_accuracy} ({value})" + with subtests.test( + msg="Classifier Sklearn vs Data", n_bits=n_bits, tolerance=sklearn_tolerance + ): + value = numpy.abs(loaded_accuracy_from_data_accuracy - sklearn_accuracy) + assert ( + value < sklearn_tolerance + ), f"{loaded_accuracy_from_data_accuracy=} != {sklearn_accuracy} ({value})" + + # Compare with CML final metric + with subtests.test( + msg="Classifier CML vs Threshold", n_bits=n_bits, tolerance=cml_tolerance + ): + value = numpy.abs(loaded_accuracy_from_threshold_accuracy - cml_accuracy) + assert ( + value < cml_tolerance + ), f"{loaded_accuracy_from_threshold_accuracy=} != {cml_accuracy} ({value})" + with subtests.test( + msg="Classifier CML vs Data", n_bits=n_bits, tolerance=cml_tolerance + ): + value = numpy.abs(loaded_accuracy_from_data_accuracy - cml_accuracy) + assert ( + value < cml_tolerance + ), f"{loaded_accuracy_from_data_accuracy=} != {cml_accuracy} ({value})" + + # Regressor + else: + for n_bits, cml_tolerance, sklearn_tolerance in [ + (max_n_bits, 0.8, 1e-5), + (reasonable_n_bits, 1.4, 1.4), + ]: + # Load a Concrete ML model from the fitted scikit-learn one + loaded_from_threshold = model_class.from_sklearn_model( + sklearn_model, + n_bits=n_bits, + ) + + loaded_from_data = model_class.from_sklearn_model( + sklearn_model, + X=x, + n_bits=n_bits, + ) + + # Compile both the initial Concrete ML model and the loaded one + concrete_model.compile(x) + mode = "disable" + if n_bits <= 8: + loaded_from_threshold.compile(x) + loaded_from_data.compile(x) + + # Compute and compare the predictions from both models + # Regressors + + # Predict + sklearn_pred = sklearn_model.predict(x) + cml_y_pred = concrete_model.predict(x, fhe=mode) + cml_threshold_y_pred = loaded_from_threshold.predict(x, fhe=mode) + cml_data_y_pred = loaded_from_data.predict(x, fhe=mode) + + # Compute metric + sklearn_mse = mean_squared_error(sklearn_pred, y) + cml_mse = mean_squared_error(cml_y_pred, y) + loaded_mse_from_threshold_mse = mean_squared_error(cml_threshold_y_pred, y) + loaded_mse_from_data_mse = mean_squared_error(cml_data_y_pred, y) + + # Compare with scikit-learn + with subtests.test( + msg="Regression Sklearn vs Threshold", n_bits=n_bits, tolerance=sklearn_tolerance + ): + value = numpy.abs(loaded_mse_from_threshold_mse - sklearn_mse) / numpy.abs(y).max() + assert ( + value < sklearn_tolerance + ), f"{loaded_mse_from_threshold_mse=} != {sklearn_mse} ({value=}>={sklearn_tolerance=})" + with subtests.test( + msg="Regression Sklearn vs Data", n_bits=n_bits, tolerance=sklearn_tolerance + ): + value = numpy.abs(loaded_mse_from_data_mse - sklearn_mse) / numpy.abs(y).max() + assert ( + value < sklearn_tolerance + ), f"{loaded_mse_from_data_mse=} != {sklearn_mse} ({value=}>={sklearn_tolerance=})" + + # # Compare with Concrete ML + with subtests.test( + msg="Regression CML vs Threshold", n_bits=n_bits, tolerance=cml_tolerance + ): + value = numpy.abs(loaded_mse_from_threshold_mse - cml_mse) / numpy.abs(y).max() + assert ( + value < cml_tolerance + ), f"{loaded_mse_from_threshold_mse=} != {cml_mse} ({value=}>={cml_tolerance=})" + with subtests.test( + msg="Regression CML vs Data", n_bits=n_bits, tolerance=cml_tolerance + ): + value = numpy.abs(loaded_mse_from_data_mse - cml_mse) / numpy.abs(y).max() + assert value < cml_tolerance, f"{loaded_mse_from_data_mse=} != {cml_mse} ({value=}>={cml_tolerance=})" + + def check_load_fitted_sklearn_linear_models(model_class, n_bits, x, y, check_float_array_equal): """Check that linear models and QNNs support loading from pre-trained scikit-learn models.""" @@ -1135,7 +1355,11 @@ def check_load_fitted_sklearn_linear_models(model_class, n_bits, x, y, check_flo model_class = get_model_class(model_class) # Load a Concrete ML model from the fitted scikit-learn one - loaded_concrete_model = model_class.from_sklearn_model(sklearn_model, X=x, n_bits=n_bits) + loaded_concrete_model = model_class.from_sklearn_model( + sklearn_model, + X=x, + n_bits=n_bits, + ) # Compile both the initial Concrete ML model and the loaded one concrete_model.compile(x) @@ -1353,7 +1577,9 @@ def test_hyper_parameters( pytest.param("recall", True), pytest.param("roc_auc", True), pytest.param( - make_scorer(matthews_corrcoef, greater_is_better=True), True, id="matthews_corrcoef" + make_scorer(matthews_corrcoef, greater_is_better=True), + True, + id="matthews_corrcoef", ), pytest.param("explained_variance", False), pytest.param("max_error", False), @@ -1533,7 +1759,8 @@ def test_inference_methods( # and needs further investigations # FIXME: https://github.com/zama-ai/concrete-ml-internal/issues/2779 @pytest.mark.parametrize( - "model_class, parameters", get_sklearn_all_models_and_datasets(ignore="RandomForest") + "model_class, parameters", + get_sklearn_all_models_and_datasets(ignore="RandomForest"), ) @pytest.mark.parametrize( "n_bits", @@ -1574,7 +1801,10 @@ def test_pipeline( n_bits for n_bits in N_BITS_WEEKLY_ONLY_BUILDS + N_BITS_REGULAR_BUILDS if n_bits - < min(N_BITS_LINEAR_MODEL_CRYPTO_PARAMETERS, N_BITS_THRESHOLD_TO_FORCE_EXECUTION_NOT_IN_FHE) + < min( + N_BITS_LINEAR_MODEL_CRYPTO_PARAMETERS, + N_BITS_THRESHOLD_TO_FORCE_EXECUTION_NOT_IN_FHE, + ) ], ) # pylint: disable=too-many-branches @@ -1757,7 +1987,8 @@ def check_for_divergent_predictions( # This test is only relevant for classifier models @pytest.mark.parametrize( - "model_class, parameters", get_sklearn_all_models_and_datasets(regressor=False, classifier=True) + "model_class, parameters", + get_sklearn_all_models_and_datasets(regressor=False, classifier=True), ) def test_class_mapping( model_class, @@ -1801,7 +2032,8 @@ def test_exposition_of_sklearn_attributes( @pytest.mark.parametrize( - "model_class, parameters", get_sklearn_tree_models_and_datasets(select="DecisionTree") + "model_class, parameters", + get_sklearn_tree_models_and_datasets(select="DecisionTree"), ) def test_exposition_structural_methods_decision_trees( model_class,