Files
fhe/ml/concrete-ml/tests/sklearn/test_sklearn_models.py
T
2026-01-25 13:58:30 -08:00

2450 lines
88 KiB
Python

"""Tests for the sklearn models.
Generic tests test:
- model (with n_bits)
- FHE simulation or not
- fit
- double fit
- compile
- grid search
- hyper parameters
- offset
- correctness (with accuracy and r2) of Concrete ML vs scikit-learn in clear
- correctness tests with fhe = "disable", "simulate" and "execute", depending on
limits (see N_BITS_THRESHOLD* constants) which are either due to execution time or limits of
the compiler or minimal number of bits for precise computations
- fit_benchmark
- r2 score / accuracies
- pandas
- pipeline
- calls to predict_proba
- calls to decision_function
"""
import copy
import inspect
import json
import math
import os
import sys
import tempfile
# pylint: disable=too-many-lines, too-many-arguments
import warnings
from typing import Any, Dict, List
import numpy
import pandas
import pytest
import sklearn
import torch
from sklearn.decomposition import PCA
from sklearn.exceptions import ConvergenceWarning, UndefinedMetricWarning
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
from torch import nn
from concrete.ml.common.serialization.dumpers import dump, dumps
from concrete.ml.common.serialization.loaders import load, loads
from concrete.ml.common.utils import (
CiphertextFormat,
array_allclose_and_same_shape,
get_model_class,
get_model_name,
is_classifier_or_partial_classifier,
is_model_class_in_a_list,
is_regressor_or_partial_regressor,
)
from concrete.ml.pytest.utils import (
MODELS_AND_DATASETS,
UNIQUE_MODELS_AND_DATASETS,
get_random_samples,
get_sklearn_all_models_and_datasets,
get_sklearn_linear_models_and_datasets,
get_sklearn_neighbors_models_and_datasets,
get_sklearn_neural_net_models_and_datasets,
get_sklearn_tree_models_and_datasets,
instantiate_model_generic,
)
from concrete.ml.sklearn import (
_get_sklearn_linear_models,
_get_sklearn_neighbors_models,
_get_sklearn_neural_net_models,
_get_sklearn_tree_models,
)
from concrete.ml.sklearn.base import BaseTreeEstimatorMixin
# Allow multiple runs in FHE to make sure we always have the correct output
N_ALLOWED_FHE_RUN = 5
# If n_bits >= N_BITS_THRESHOLD_FOR_SKLEARN_CORRECTNESS_TESTS, we check correctness against
# scikit-learn in the clear, via check_correctness_with_sklearn function. This is because we need
# sufficiently number of bits for precision
N_BITS_THRESHOLD_FOR_SKLEARN_CORRECTNESS_TESTS = 26
# We never do checks with check_is_good_execution_for_cml_vs_circuit if
# n_bits >= N_BITS_THRESHOLD_TO_FORCE_EXECUTION_NOT_IN_FHE. This is because computations are very
# slow
N_BITS_THRESHOLD_TO_FORCE_EXECUTION_NOT_IN_FHE = 17
# If n_bits >= N_BITS_THRESHOLD_FOR_SKLEARN_EQUIVALENCE_TESTS, we check that the two models
# returned by fit_benchmark (the Concrete ML model and the scikit-learn model) are equivalent
N_BITS_THRESHOLD_FOR_SKLEARN_EQUIVALENCE_TESTS = 16
# There is a risk that no cryptographic parameters are available for high precision linear
# models. N_BITS_LINEAR_MODEL_CRYPTO_PARAMETERS makes sure we do not create linear models
# that do not have cryptographic parameters.
N_BITS_LINEAR_MODEL_CRYPTO_PARAMETERS = 11
# n_bits that we test, either in regular builds or just in weekly builds. 6 is to do tests in
# FHE which are not too long (relation with N_BITS_THRESHOLD_TO_FORCE_EXECUTION_NOT_IN_FHE).
# 26 is in relation with N_BITS_THRESHOLD_FOR_SKLEARN_CORRECTNESS_TESTS, to do tests with
# check_correctness_with_sklearn
N_BITS_REGULAR_BUILDS = [6, 26]
N_BITS_WEEKLY_ONLY_BUILDS = [2, 8, 16]
# Circuit with 9 bits up to 16 bits are currently using the CRT circuit. We do not test them here
# as they take a bit more time than non-CRT based FHE circuit.
# N_BITS_THRESHOLD_FOR_CRT_FHE_CIRCUITS defines the threshold for which the circuit will be using
# the CRT.
N_BITS_THRESHOLD_FOR_CRT_FHE_CIRCUITS = 9
# Expected different default parameters for some models
EXPECTED_DIFFERENT_DEFAULT_PARAMETERS = {
"KNeighborsClassifier": {"n_neighbors": 3},
"SGDClassifier": {"loss": "log_loss"},
"RandomForestClassifier": {"n_estimators": 20, "max_depth": 4},
"RandomForestRegressor": {"n_estimators": 20, "max_depth": 4},
"XGBClassifier": {"n_estimators": 20, "max_depth": 3},
"XGBRegressor": {"n_estimators": 20, "max_depth": 3},
}
def get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option):
"""Prepare the the (x, y) data-set."""
if not is_model_class_in_a_list(
model_class, _get_sklearn_linear_models() + _get_sklearn_neighbors_models()
):
if n_bits in N_BITS_WEEKLY_ONLY_BUILDS and not is_weekly_option:
pytest.skip("Skipping some tests in non-weekly builds")
# Get the data-set. The data generation is seeded in load_data.
x, y = load_data(model_class, **parameters)
return x, y
def preamble(model_class, parameters, n_bits, load_data, is_weekly_option):
"""Prepare the fitted model, and the (x, y) data-set."""
if not is_model_class_in_a_list(
model_class, _get_sklearn_linear_models() + _get_sklearn_neighbors_models()
):
if n_bits in N_BITS_WEEKLY_ONLY_BUILDS and not is_weekly_option:
pytest.skip("Skipping some tests in non-weekly builds")
# Get the data-set. The data generation is seeded in load_data.
model = instantiate_model_generic(model_class, n_bits=n_bits)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
model.fit(x, y)
return model, x
def get_n_bits_non_correctness(model_class):
"""Get the number of bits to use for non correctness related tests."""
# KNN can only be compiled with small quantization bit numbers for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3979
if get_model_name(model_class) == "KNeighborsClassifier":
n_bits = 2
else:
n_bits = min(N_BITS_REGULAR_BUILDS)
return n_bits
def fit_and_compile(model, x, y):
"""Fit the model and compile it."""
model.fit(x, y)
model.compile(x)
def check_correctness_with_sklearn(
model_class,
x,
y,
n_bits,
check_r2_score,
check_accuracy,
fhe="disable",
hyper_parameters=None,
):
"""Check that Concrete ML and scikit-learn models are 'equivalent'."""
if hyper_parameters is None:
hyper_parameters = {}
model = instantiate_model_generic(model_class, n_bits=n_bits, **hyper_parameters)
model, sklearn_model = model.fit_benchmark(x, y)
model_name = get_model_name(model_class)
acceptance_r2score = 0.9
threshold_accuracy = 0.9
# If the model is a classifier
# KNeighborsClassifier does not provide a predict_proba method for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3962
if (
is_classifier_or_partial_classifier(model)
and get_model_name(model_class) != "KNeighborsClassifier"
):
if is_model_class_in_a_list(model, _get_sklearn_linear_models()):
# Check outputs from the 'decision_function' method (for linear classifiers)
y_scores_sklearn = sklearn_model.decision_function(x)
y_scores_fhe = model.decision_function(x, fhe=fhe)
assert y_scores_sklearn.shape == y_scores_fhe.shape, (
"Method 'decision_function' outputs different shapes between scikit-learn and "
f"Concrete ML in FHE (fhe={fhe})"
)
check_r2_score(y_scores_sklearn, y_scores_fhe, acceptance_score=acceptance_r2score)
# LinearSVC models from scikit-learn do not provide a 'predict_proba' method
if get_model_name(model_class) != "LinearSVC":
# Check outputs from the 'predict_proba' method (for all classifiers,
# except KNeighborsClassifier)
y_proba_sklearn = sklearn_model.predict_proba(x)
y_proba_fhe = model.predict_proba(x, fhe=fhe)
assert y_proba_sklearn.shape == y_proba_fhe.shape, (
"Method 'decision_function' outputs different shapes between scikit-learn and "
f"Concrete ML in FHE (fhe={fhe})"
)
check_r2_score(y_proba_sklearn, y_proba_fhe, acceptance_score=acceptance_r2score)
# Check outputs from the 'predict_proba' method (for all models)
y_pred_sklearn = sklearn_model.predict(x)
y_pred_fhe = model.predict(x, fhe=fhe)
assert y_pred_sklearn.shape == y_pred_fhe.shape, (
"Method 'predict' outputs different shapes between scikit-learn and "
f"Concrete ML in FHE (fhe={fhe})"
)
# If the model is a classifier, check that accuracies are similar
if is_classifier_or_partial_classifier(model):
check_accuracy(y_pred_sklearn, y_pred_fhe, threshold=threshold_accuracy)
# If the model is a regressor, check that R2 scores are similar
elif is_regressor_or_partial_regressor(model):
check_r2_score(y_pred_sklearn, y_pred_fhe, acceptance_score=acceptance_r2score)
else:
raise AssertionError(f"Model {model_name} is neither a classifier nor a regressor.")
def check_double_fit(model_class, n_bits, x_1, x_2, y_1, y_2):
"""Check double fit."""
if get_model_name(model_class) == "KNeighborsClassifier":
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/4014
pytest.skip(
"Given that KNN is not accurate and the test data-set is small"
"the y_pred1 and y_pred2 can be equal."
)
model = instantiate_model_generic(model_class, n_bits=n_bits)
# Set the torch seed manually before fitting a neural network
if is_model_class_in_a_list(model_class, _get_sklearn_neural_net_models()):
# Generate a seed for PyTorch
main_seed = numpy.random.randint(0, 2**63)
torch.manual_seed(main_seed)
# Fit and predict on the first dataset
model.fit(x_1, y_1)
y_pred_1 = model.predict(x_1)
# Store the input and output quantizers
input_quantizers_1 = copy.copy(model.input_quantizers)
output_quantizers_1 = copy.copy(model.output_quantizers)
# Set the same torch seed manually before re-fitting the neural network
if is_model_class_in_a_list(model_class, _get_sklearn_neural_net_models()):
torch.manual_seed(main_seed)
# Re-fit on the second dataset
model.fit(x_2, y_2)
# Check that predictions are different
y_pred_2 = model.predict(x_2)
assert not numpy.array_equal(y_pred_1, y_pred_2)
# Store the new input and output quantizers
input_quantizers_2 = copy.copy(model.input_quantizers)
output_quantizers_2 = copy.copy(model.output_quantizers)
# Random forest and decision tree classifiers can have identical output_quantizers
# This is because targets are integers, while these models have a fixed output
# precision, which leads the output scale to be the same between models with similar target
# classes range
if is_model_class_in_a_list(
model_class,
_get_sklearn_tree_models(classifier=True, select=["RandomForest", "DecisionTree"]),
):
quantizers_1 = input_quantizers_1
quantizers_2 = input_quantizers_2
else:
quantizers_1 = input_quantizers_1 + output_quantizers_1
quantizers_2 = input_quantizers_2 + output_quantizers_2
# Check that the new quantizers are different from the first ones. This is because we
# currently expect all quantizers to be re-computed when re-fitting a model
assert all(
quantizer_1 != quantizer_2 for (quantizer_1, quantizer_2) in zip(quantizers_1, quantizers_2)
)
# Set the same torch seed manually before re-fitting the neural network
if is_model_class_in_a_list(model_class, _get_sklearn_neural_net_models()):
torch.manual_seed(main_seed)
# Re-fit on the first dataset again
model.fit(x_1, y_1)
# Check that predictions are identical to the first ones
y_pred_3 = model.predict(x_1)
assert numpy.array_equal(y_pred_1, y_pred_3)
# Store the new input and output quantizers
input_quantizers_3 = copy.copy(model.input_quantizers)
output_quantizers_3 = copy.copy(model.output_quantizers)
# Check that the new quantizers are identical from the first ones. Again, we expect the
# quantizers to be re-computed when re-fitting. Since we used the same dataset as the first
# fit, we also expect these quantizers to be the same.
assert all(
quantizer_1 == quantizer_3
for (quantizer_1, quantizer_3) in zip(
input_quantizers_1 + output_quantizers_1,
input_quantizers_3 + output_quantizers_3,
)
)
def check_serialization(model, x, use_dump_method):
"""Check serialization."""
check_serialization_dump_load(model, x, use_dump_method)
check_serialization_dumps_loads(model, x, use_dump_method)
def check_serialization_dump_load(model, x, use_dump_method):
"""Check that a model can be serialized two times using dump/load."""
with tempfile.TemporaryFile("w+") as temp_dump:
# Dump the model into the file
temp_dump.seek(0)
temp_dump.truncate(0)
if use_dump_method:
model.dump(file=temp_dump)
else:
dump(model, file=temp_dump)
# Load the model from the file as a dict using json
temp_dump.seek(0)
serialized_model_dict: Dict = json.load(temp_dump)
# Load the model from the file using Concrete ML's method
temp_dump.seek(0)
loaded_model = load(file=temp_dump)
# Dump the loaded model into the file using Concrete ML's method
temp_dump.seek(0)
temp_dump.truncate(0)
if use_dump_method:
loaded_model.dump(file=temp_dump)
else:
dump(loaded_model, file=temp_dump)
# Load the model from the file again as a dict using json
temp_dump.seek(0)
re_serialized_model_dict: Dict = json.load(temp_dump)
# Check that the dictionaries are identical
# We exclude attributes such as `sklearn_model` (for linear and tree-based models) or
# `params` (neural networks) since they are serialized using the pickle library, which does
# not handle double serialization)
for attribute in [
"sklearn_model",
"params",
"criterion",
"optimizer",
"iterator_train",
"iterator_valid",
"dataset",
"module__activation_function",
]:
serialized_model_dict["serialized_value"].pop(attribute, None)
re_serialized_model_dict["serialized_value"].pop(attribute, None)
# Check if the serialized models are identical
assert serialized_model_dict == re_serialized_model_dict
# Check that the predictions made by both model are identical
y_pred_model = model.predict(x)
y_pred_loaded_model = loaded_model.predict(x)
assert numpy.array_equal(y_pred_model, y_pred_loaded_model)
# Check that the predictions made by both Scikit-Learn model are identical
y_pred_sklearn_model = model.sklearn_model.predict(x)
y_pred_loaded_sklearn_model = loaded_model.sklearn_model.predict(x)
assert numpy.array_equal(y_pred_sklearn_model, y_pred_loaded_sklearn_model)
# Add a test to check that graphs before and after the serialization are identical
# FIME: https://github.com/luxfi/concrete-ml-internal/issues/4175
def check_serialization_dumps_loads(model, x, use_dump_method):
"""Check that a model can be serialized two times using dumps/loads."""
# Dump the model as a string
if use_dump_method:
serialized_model_str = model.dumps()
else:
serialized_model_str = dumps(model)
# Load the model from the string
loaded_model = loads(serialized_model_str)
# Dump the model as a string again
if use_dump_method:
re_serialized_model_str: str = loaded_model.dumps()
else:
re_serialized_model_str: str = dumps(loaded_model) # type: ignore[no-redef]
# Load both strings using json
serialized_model_dict: Dict = json.loads(serialized_model_str)
re_serialized_model_dict: Dict = json.loads(re_serialized_model_str)
# Check that the dictionaries are identical
# We exclude attributes such as `sklearn_model` (for linear and tree-based models) or
# `params` (neural networks) since they are serialized using the pickle library, which does
# not handle double serialization)
for attribute in [
"sklearn_model",
"params",
"criterion",
"optimizer",
"iterator_train",
"iterator_valid",
"dataset",
"module__activation_function",
]:
serialized_model_dict["serialized_value"].pop(attribute, None)
re_serialized_model_dict["serialized_value"].pop(attribute, None)
# Check if the serialized models are identical
assert serialized_model_dict == re_serialized_model_dict
# Check that the predictions made by both model are identical
y_pred_model = model.predict(x)
y_pred_loaded_model = loaded_model.predict(x)
assert numpy.array_equal(y_pred_model, y_pred_loaded_model)
# Check that the predictions made by both Scikit-Learn model are identical
y_pred_sklearn_model = model.sklearn_model.predict(x)
y_pred_loaded_sklearn_model = loaded_model.sklearn_model.predict(x)
assert numpy.array_equal(y_pred_sklearn_model, y_pred_loaded_sklearn_model)
# Add a test to check that graphs before and after the serialization are identical
# FIME: https://github.com/luxfi/concrete-ml-internal/issues/4175
def check_offset(model_class, n_bits, x, y):
"""Check offset."""
model = instantiate_model_generic(model_class, n_bits=n_bits)
# Add the offset: here, we really need to fit, we can't reuse an already fitted model
y += 3
model.fit(x, y)
model.predict(x[:1])
# Another offset: here, we really need to fit, we can't reuse an already fitted model
y -= 2
model.fit(x, y)
def check_inference_methods(model, model_class, x, check_float_array_equal):
"""Check that all inference methods provided are coherent between clear and FHE executions."""
# skorch provides a predict_proba method for neural network regressors while Scikit-Learn does
# not. We decided to follow Scikit-Learn's API as we build most of our tools on this library.
# However, our models are still directly inheriting from skorch's classes, which makes this
# method accessible by anyone, without having any FHE implementation. As this could create some
# confusion, a NotImplementedError is raised. This issue could be fixed by making these classes
# not inherit from skorch.
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3373
if get_model_name(model) == "NeuralNetRegressor":
with pytest.raises(
NotImplementedError,
match=(
"The `predict_proba` method is not implemented for neural network regressors. "
"Please call `predict` instead."
),
):
model.predict_proba(x)
# KNeighborsClassifier does not provide a predict_proba method for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3962
elif get_model_name(model) == "KNeighborsClassifier":
with pytest.raises(
NotImplementedError,
match=(
"The `predict_proba` method is not implemented for KNeighborsClassifier. "
"Please call `predict` instead."
),
):
model.predict_proba(x)
# KNeighborsClassifier does not provide a kneighbors method
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/4080
with pytest.raises(
NotImplementedError,
match=(
"The `kneighbors` method is not implemented for KNeighborsClassifier. Please call "
"`get_topk_labels` to retieve the K-Nearest labels for each point, or `predict` "
"method to retieve the predicted label for each data point."
),
):
model.kneighbors(x)
# Only check 'predict_proba' and not 'predict' as some issues were found with the argmax not
# being consistent because of precision errors with epsilon magnitude. This argmax should be
# done in the clear the same way for both anyway. Ultimately, we would want to only compare the
# circuit's quantized outputs against the ones computed in the clear but built-in models do not
# currently provide the necessary API for that
elif is_classifier_or_partial_classifier(model_class):
if is_model_class_in_a_list(model_class, _get_sklearn_linear_models()):
# Check outputs from the 'decision_function' method (for all linear classifiers)
y_scores_clear = model.decision_function(x)
y_scores_simulated = model.decision_function(x, fhe="simulate")
assert y_scores_clear.shape == y_scores_simulated.shape, (
"Method 'decision_function' from Concrete ML outputs different shapes when executed"
"in the clear and with simulation."
)
check_float_array_equal(y_scores_clear, y_scores_simulated)
else:
# Check outputs from the 'predict_proba' method (for all non-linear classifiers,
# except KNeighborsClassifier)
y_proba_clear = model.predict_proba(x)
y_proba_simulated = model.predict_proba(x, fhe="simulate")
assert y_proba_clear.shape == y_proba_simulated.shape, (
"Method 'predict_proba' from Concrete ML outputs different shapes when executed"
"in the clear and with simulation."
)
check_float_array_equal(y_proba_clear, y_proba_simulated)
else:
# Check outputs from the 'predict' method (for all regressors and KNeighborsClassifier)
y_pred_clear = model.predict(x)
y_pred_simulated = model.predict(x, fhe="simulate")
assert y_pred_clear.shape == y_pred_simulated.shape, (
"Method 'predict' from Concrete ML outputs different shapes when executed in the clear "
"and with simulation."
)
check_float_array_equal(y_pred_clear, y_pred_simulated)
def check_separated_inference(model, fhe_circuit, x, check_float_array_equal):
"""Run inference methods in separated steps and check their correctness."""
# Generate the keys
fhe_circuit.keygen()
# Quantize an input (float)
q_x = model.quantize_input(x)
q_y_pred_list = []
for q_x_i in q_x:
# Expected input shape for 'encrypt' method is (1, n_features) while q_x_i
# is of shape (n_features,)
q_x_i = numpy.expand_dims(q_x_i, 0)
# Encrypt the input
q_x_encrypted_i = fhe_circuit.encrypt(q_x_i)
# Execute the linear product in FHE
q_y_pred_encrypted_i = fhe_circuit.run(q_x_encrypted_i)
# Decrypt the result (integer)
q_y_pred_i = fhe_circuit.decrypt(q_y_pred_encrypted_i)
q_y_pred_list.append(q_y_pred_i[0])
q_y_pred = numpy.array(q_y_pred_list)
# De-quantize the result
y_pred = model.dequantize_output(q_y_pred)
if is_model_class_in_a_list(
model, _get_sklearn_linear_models(classifier=True, regressor=False)
):
y_scores = model.decision_function(x, fhe="simulate")
# For linear classifiers, the circuit's de-quantized outputs should be the same as the ones
# from the `decision_function` built-in method
check_float_array_equal(y_pred, y_scores)
# Apply post-processing step (in the clear)
# This includes (non-exhaustive):
# - sigmoid or softmax function for classifiers
# - final sum for tree-based models
# - link function for GLMs
y_pred = model.post_processing(y_pred)
# KNeighborsClassifier does not provide a predict_proba method for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3962
if (
is_classifier_or_partial_classifier(model)
and get_model_name(model) != "KNeighborsClassifier"
):
y_proba = model.predict_proba(x, fhe="simulate")
else:
y_proba = model.predict(x, fhe="simulate")
# The circuit's de-quantized outputs followed by `post_processing` should be the same as the
# ones from the `predict_proba` built-in method for classifiers, and from the `predict`
# built-in method for regressors
check_float_array_equal(y_pred, y_proba)
# KNeighborsClassifier does not apply a final argmax for computing prediction
if (
is_classifier_or_partial_classifier(model)
and get_model_name(model) != "KNeighborsClassifier"
):
# For linear classifiers, the argmax is done on the scores directly, not the probabilities
# Also, it is handled differently if shape is (n,) instead of (n, 1)
if is_model_class_in_a_list(model, _get_sklearn_linear_models()):
if y_scores.ndim == 1:
y_pred = (y_scores > 0).astype(int)
else:
y_pred = numpy.argmax(y_scores, axis=1)
else:
y_pred = numpy.argmax(y_pred, axis=1)
y_pred_class = model.predict(x, fhe="simulate")
# For classifiers (other than KNeighborsClassifier), the circuit's de-quantized outputs
# followed by `post_processing` as well as an argmax should be the same as the ones from
# the `predict` built-in method
check_float_array_equal(y_pred, y_pred_class)
def check_input_support(model_class, n_bits, default_configuration, x, y, input_type):
"""Test all models with Pandas, List or Torch inputs."""
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"
if input_type.lower() == "pandas":
# Turn into Pandas
x = pandas.DataFrame(x)
y = pandas.Series(y) if y.ndim == 1 else pandas.DataFrame(y)
elif input_type.lower() == "torch":
# Turn into Torch
x = torch.tensor(x)
y = torch.tensor(y)
elif input_type.lower() == "list":
# Turn into List
x = x.tolist()
y = y.tolist()
elif input_type.lower() == "numpy":
assert isinstance(x, numpy.ndarray), f"Wrong type {type(x)}"
assert isinstance(y, numpy.ndarray), f"Wrong type {type(y)}"
return x, y
model = instantiate_model_generic(model_class, n_bits=n_bits)
x, y = cast_input(x, y, input_type=input_type)
model.fit(x, y)
# Make sure `predict` is working when FHE is disabled
model.predict(x)
# Similarly, we test `predict_proba` for classifiers
# KNeighborsClassifier does not provide a predict_proba method for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3962
if (
is_classifier_or_partial_classifier(model)
and get_model_name(model_class) != "KNeighborsClassifier"
):
model.predict_proba(x)
model.compile(x, default_configuration)
# Make sure `predict` is working when FHE is disabled
model.predict(x, fhe="simulate")
# Similarly, we test `predict_proba` for classifiers
# KNeighborsClassifier does not provide a predict_proba method for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3962
if (
is_classifier_or_partial_classifier(model)
and get_model_name(model_class) != "KNeighborsClassifier"
):
model.predict_proba(x, fhe="simulate")
def check_pipeline(model_class, x, y):
"""Check pipeline support."""
hyper_param_combinations = get_hyper_param_combinations(model_class)
# Prepare the list of all hyper parameters
hyperparameters_list = [
{key: value} for key, values in hyper_param_combinations.items() for value in values
]
# Take one of the hyper_parameters randomly (testing everything would be too long)
if len(hyperparameters_list) == 0:
hyper_parameters = {}
else:
hyper_parameters = hyperparameters_list[numpy.random.randint(0, len(hyperparameters_list))]
pipe_cv = Pipeline(
[
("pca", PCA(n_components=2, random_state=numpy.random.randint(0, 2**15))),
("scaler", StandardScaler()),
("model", model_class(**hyper_parameters)),
]
)
# Do a grid search to find the best hyper-parameters
if is_model_class_in_a_list(model_class, _get_sklearn_neural_net_models()):
param_grid = {
"model__module__n_w_bits": [2, 3],
"model__module__n_a_bits": [2, 3],
}
else:
param_grid = {
"model__n_bits": [2, 3],
}
# We need a small number of splits, especially for the KNN model, which has a small data-set
grid_search = GridSearchCV(pipe_cv, param_grid, error_score="raise", cv=2)
grid_search.fit(x, y)
def check_grid_search(model_class, x, y, scoring):
"""Check grid search."""
if is_model_class_in_a_list(model_class, _get_sklearn_neural_net_models()):
param_grid = {
"module__n_layers": [2, 3],
"module__n_hidden_neurons_multiplier": [1],
"module__activation_function": (nn.ReLU6,),
}
elif model_class in _get_sklearn_tree_models(select="DecisionTree"):
param_grid = {
"n_bits": [20],
}
elif model_class in _get_sklearn_tree_models():
param_grid = {
"n_bits": [20],
"max_depth": [2],
"n_estimators": [5, 10],
"n_jobs": [1],
}
elif model_class in _get_sklearn_neighbors_models():
param_grid = {"n_bits": [2], "n_neighbors": [2]}
else:
param_grid = {
"n_bits": [20],
}
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UndefinedMetricWarning)
# KNeighborsClassifier does not provide a predict_proba method for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3962
if get_model_name(model_class) == "KNeighborsClassifier" and scoring in [
"roc_auc",
"average_precision",
]:
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,
).fit(x, y)
def get_hyper_param_combinations(model_class):
"""Return the hyper_param_combinations, depending on the model class"""
hyper_param_combinations: Dict[str, List[Any]]
if is_model_class_in_a_list(model_class, _get_sklearn_linear_models()):
hyper_param_combinations = {"fit_intercept": [False, True]}
elif model_class in _get_sklearn_tree_models(select="DecisionTree"):
hyper_param_combinations = {}
elif model_class in _get_sklearn_tree_models(select="RandomForest"):
hyper_param_combinations = {
"max_depth": [3, 4, 5, 10],
"min_samples_split": [2, 3, 4, 5],
"min_samples_leaf": [1, 2, 3, 4],
"min_weight_fraction_leaf": [0.0, 0.1, 0.2, 0.3],
"max_features": ["sqrt", "log2"],
"max_leaf_nodes": [None, 5, 10, 20],
}
elif model_class in _get_sklearn_tree_models(select="XGB"):
hyper_param_combinations = {
"max_depth": [3, 4, 5, 10],
"learning_rate": [1, 0.5, 0.1],
"n_estimators": [1, 50, 100, 1000],
"tree_method": ["auto", "exact", "approx"],
"gamma": [0, 0.1, 0.5],
"min_child_weight": [1, 5, 10],
"max_delta_step": [0, 0.5, 0.7],
"subsample": [0.5, 0.9, 1.0],
"colsample_bytree": [0.5, 0.9, 1.0],
"colsample_bylevel": [0.5, 0.9, 1.0],
"colsample_bynode": [0.5, 0.9, 1.0],
"reg_alpha": [0, 0.1, 0.5],
"reg_lambda": [0, 0.1, 0.5],
"scale_pos_weight": [0.5, 0.9, 1.0],
"importance_type": ["weight", "gain"],
"base_score": [0.5, None],
}
elif model_class in _get_sklearn_neighbors_models():
# Use small `n_neighbors` values for KNN, because the data-set is too small for now
hyper_param_combinations = {"n_neighbors": [1, 2]}
else:
assert is_model_class_in_a_list(
model_class, _get_sklearn_neural_net_models()
), "models are supposed to be tree-based or linear or QNN's"
hyper_param_combinations = {}
# Don't put n_bits in hyper_parameters, it comes from the test itself
assert "n_bits" not in hyper_param_combinations
return hyper_param_combinations
def check_hyper_parameters(
model_class,
n_bits,
x,
y,
check_r2_score,
check_accuracy,
):
"""Check hyper parameters."""
hyper_param_combinations = get_hyper_param_combinations(model_class)
# Prepare the list of all hyper parameters
hyperparameters_list = [
{key: value} for key, values in hyper_param_combinations.items() for value in values
]
for hyper_parameters in hyperparameters_list:
model = instantiate_model_generic(model_class, n_bits=n_bits, **hyper_parameters)
# Also fit with these hyper parameters to check it works fine
# Here, we really need to fit, to take into account hyper parameters
model.fit(x, y)
# Check correctness with sklearn
check_correctness_with_sklearn(
model_class,
x,
y,
n_bits,
check_r2_score,
check_accuracy,
fhe="disable",
hyper_parameters=hyper_parameters,
)
def check_fitted_compiled_error_raises(model_class, n_bits, x, y):
"""Check that methods that require the model to be compiled or fitted raise proper errors."""
model = instantiate_model_generic(model_class, n_bits=n_bits)
# Quantizing inputs with an untrained model should not be possible
with pytest.raises(AttributeError, match=".* model is not fitted.*"):
model.quantize_input(x)
# Quantizing outputs with an untrained model should not be possible
with pytest.raises(AttributeError, match=".* model is not fitted.*"):
model.dequantize_output(x)
# Compiling an untrained model should not be possible
with pytest.raises(AttributeError, match=".* model is not fitted.*"):
model.compile(x)
# Predicting in FHE using an untrained model should not be possible
with pytest.raises(AttributeError, match=".* model is not fitted.*"):
model.predict(x, fhe="execute")
# Predicting in clear using an untrained model should not be possible for linear and
# tree-based models
if not is_model_class_in_a_list(model_class, _get_sklearn_neural_net_models()):
with pytest.raises(AttributeError, match=".* model is not fitted.*"):
model.predict(x)
# KNeighborsClassifier does not provide a predict_proba method for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3962
if (
is_classifier_or_partial_classifier(model_class)
and get_model_name(model) != "KNeighborsClassifier"
):
# Predicting probabilities using an untrained linear or tree-based classifier should not
# be possible
if not is_model_class_in_a_list(model_class, _get_sklearn_neural_net_models()):
with pytest.raises(AttributeError, match=".* model is not fitted.*"):
model.predict_proba(x)
# Predicting probabilities in FHE using an untrained QNN classifier should not be possible
else:
with pytest.raises(AttributeError, match=".* model is not fitted.*"):
model.predict_proba(x, fhe="execute")
# Computing the decision function using an untrained classifier should not be possible.
# Note that the `decision_function` method is only available for linear models
if is_model_class_in_a_list(model_class, _get_sklearn_linear_models()):
with pytest.raises(AttributeError, match=".* model is not fitted.*"):
model.decision_function(x)
model.fit(x, y)
# Predicting in FHE using a trained model that is not compiled should not be possible
with pytest.raises(AttributeError, match=".* model is not compiled.*"):
model.predict(x, fhe="execute")
# Predicting probabilities in FHE using a trained QNN classifier that is not compiled should
# not be possible
if is_classifier_or_partial_classifier(model_class) and is_model_class_in_a_list(
model_class, _get_sklearn_neural_net_models()
):
with pytest.raises(AttributeError, match=".* model is not compiled.*"):
model.predict_proba(x, fhe="execute")
def check_class_mapping(model, x, y):
"""Check that classes with arbitrary labels are handled for all classifiers."""
# Retrieve the data's target labels
classes = numpy.unique(y)
# Make sure these targets are ordered by default
assert numpy.array_equal(numpy.arange(len(classes)), classes)
# Fit the model
model.fit(x, y)
# Compute the predictions
y_pred = model.predict(x)
# Shuffle the initial labels (in place)
numpy.random.shuffle(classes)
# Map each targets' label to the the new shuffled ones
new_y = classes[y]
# Fit the model using these new targets
model.fit(x, new_y)
# Compute the predictions
y_pred_shuffled = model.predict(x)
# Check that the mapping of labels was kept by Concrete ML
numpy.array_equal(classes[y_pred], y_pred_shuffled)
def check_exposition_of_sklearn_attributes(model, x, y):
"""Check training scikit-learn attributes are properly exposed in our models."""
training_attribute = "coef_"
# Check that accessing an attribute that follows scikit-learn's naming convention for training
# attributes by ending with an underscore properly raises an Attribute error when the model is
# not fitted
with pytest.raises(
AttributeError,
match=f".* {training_attribute} cannot be found in the Concrete ML.*",
):
getattr(model, training_attribute)
model.fit(x, y)
for name in vars(model.sklearn_model):
if name.endswith("_") and not name.endswith("__"):
assert hasattr(
model, name
), f"Training attribute {name} is not exposed in {get_model_name(model)} model."
wrong_training_attribute_1 = "concrete_ml"
# Check that accessing an unknown attribute properly raises an Attribute error
with pytest.raises(
AttributeError,
match=f".* {wrong_training_attribute_1} cannot be found in the Concrete ML.*",
):
getattr(model, wrong_training_attribute_1)
wrong_training_attribute_2 = "concrete_ml_"
# Check that accessing an unknown attribute that almost follows scikit-learn's naming
# convention for training attributes by ending with two underscores properly raises an
# Attribute error
with pytest.raises(
AttributeError,
match=f".* object has no attribute '{wrong_training_attribute_2}'",
):
getattr(model, wrong_training_attribute_2)
wrong_training_attribute_3 = "concrete_ml__"
# Check that accessing an unknown attribute that almost follows scikit-learn's naming
# convention for training attributes by ending with two underscores properly raises an
# Attribute error
with pytest.raises(
AttributeError,
match=f".* {wrong_training_attribute_3} cannot be found in the Concrete ML.*",
):
getattr(model, wrong_training_attribute_3)
def check_exposition_structural_methods_decision_trees(model, x, y):
"""Check structural methods from scikit-learn are properly exposed in decision tree models."""
# Check that accessing an attribute that follows scikit-learn's naming convention for training
# attributes by ending with an underscore properly raises an Attribute error when the model is
# not fitted
with pytest.raises(
AttributeError,
match=".* get_n_leaves cannot be found in the Concrete ML.*",
):
model.get_n_leaves()
with pytest.raises(
AttributeError,
match=".* get_depth cannot be found in the Concrete ML.*",
):
model.get_depth()
model.fit(x, y)
# Get the number of leaves from both the scikit-learn and Concrete ML models
concrete_value = model.get_n_leaves()
sklearn_value = model.sklearn_model.get_n_leaves()
model_name = get_model_name(model)
assert concrete_value == sklearn_value, (
f"Method get_n_leaves of model {model_name} do not output the same value as with its "
f"scikit-learn equivalent. Got {concrete_value}, expected {sklearn_value}."
)
# Get the tree depth from both the scikit-learn and Concrete ML models
concrete_value = model.get_depth()
sklearn_value = model.sklearn_model.get_depth()
model_name = get_model_name(model)
assert concrete_value == sklearn_value, (
f"Method get_depth of model {model_name} do not output the same value as with its "
f"scikit-learn equivalent. Got {concrete_value}, expected {sklearn_value}."
)
# pylint: disable=too-many-locals,too-many-statements
@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)
with pytest.MonkeyPatch.context() as mp_context:
mp_context.setenv("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 = 10
if is_model_class_in_a_list(
model_class,
_get_sklearn_tree_models(classifier=True, regressor=False),
):
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 <= reasonable_n_bits:
mode = "simulate"
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
elif is_model_class_in_a_list(
model_class,
_get_sklearn_tree_models(regressor=True, classifier=False),
):
for n_bits, cml_tolerance, sklearn_tolerance in [
(max_n_bits, 0.8, 1e-5),
(reasonable_n_bits, 1.8, 1.8),
]:
# 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 <= reasonable_n_bits:
mode = "simulate"
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} "
f"({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}"
f"({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=} !=" f" {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."""
model = instantiate_model_generic(model_class, n_bits=n_bits)
# Fit the model and retrieve both the Concrete ML and the scikit-learn models
concrete_model, sklearn_model = model.fit_benchmark(x, y)
# This step is needed in order to handle partial classes
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,
)
# Compile both the initial Concrete ML model and the loaded one
concrete_model.compile(x)
loaded_concrete_model.compile(x)
# Compute and compare the predictions from both models
y_pred_simulate = concrete_model.predict(x, fhe="simulate")
y_pred_simulate_loaded = loaded_concrete_model.predict(x, fhe="simulate")
check_float_array_equal(
y_pred_simulate,
y_pred_simulate_loaded,
error_information="Simulated predictions from the initial model do not match the ones made "
"from the loaded one.",
)
def check_rounding_consistency(
model,
x,
y,
predict_method,
metric,
):
"""Test that Concrete ML without and with rounding are 'equivalent'."""
# Check that rounding is enabled
assert os.environ.get("TREES_USE_ROUNDING") == "1", "'TREES_USE_ROUNDING' is not enabled"
# Fit and compile with rounding enabled
fit_and_compile(model, x, y)
rounded_predict_quantized = predict_method(x, fhe="disable")
rounded_predict_simulate = predict_method(x, fhe="simulate")
with pytest.MonkeyPatch.context() as mp_context:
# Disable rounding
mp_context.setenv("TREES_USE_ROUNDING", "0")
# Check that rounding is disabled
assert os.environ.get("TREES_USE_ROUNDING") == "0", "'TREES_USE_ROUNDING' is not disabled"
with pytest.warns(
DeprecationWarning,
match=(
"Using Concrete tree-based models without the `rounding feature` is " "deprecated.*"
),
):
# Fit and compile without rounding
fit_and_compile(model, x, y)
not_rounded_predict_quantized = predict_method(x, fhe="disable")
not_rounded_predict_simulate = predict_method(x, fhe="simulate")
metric(rounded_predict_quantized, not_rounded_predict_quantized)
metric(rounded_predict_simulate, not_rounded_predict_simulate)
# Check that the maximum bit-width of the circuit with rounding is at most:
# maximum bit-width (of the circuit without rounding) + 2
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/4178
def check_sum_for_tree_based_models(
model,
x,
y,
predict_method,
is_weekly_option,
):
"""Test that Concrete ML without and with FHE sum are 'equivalent'."""
fhe_samples = 5
fhe_test = get_random_samples(x, n_sample=fhe_samples)
# pylint: disable=protected-access
assert not model._fhe_ensembling, "`_fhe_ensembling` is disabled by default."
fit_and_compile(model, x, y)
non_fhe_sum_predict_quantized = predict_method(x, fhe="disable")
non_fhe_sum_predict_simulate = predict_method(x, fhe="simulate")
if is_weekly_option:
non_fhe_sum_predict_fhe = predict_method(fhe_test, fhe="execute")
# Sanity check
array_allclose_and_same_shape(non_fhe_sum_predict_quantized, non_fhe_sum_predict_simulate)
# pylint: disable=protected-access
model._fhe_ensembling = True
fit_and_compile(model, x, y)
fhe_sum_predict_quantized = predict_method(x, fhe="disable")
fhe_sum_predict_simulate = predict_method(x, fhe="simulate")
if is_weekly_option:
fhe_sum_predict_fhe = predict_method(fhe_test, fhe="execute")
# Sanity check
array_allclose_and_same_shape(fhe_sum_predict_quantized, fhe_sum_predict_simulate)
# Check that we have the exact same predictions
array_allclose_and_same_shape(fhe_sum_predict_quantized, non_fhe_sum_predict_quantized)
array_allclose_and_same_shape(fhe_sum_predict_simulate, non_fhe_sum_predict_simulate)
if is_weekly_option:
array_allclose_and_same_shape(fhe_sum_predict_fhe, non_fhe_sum_predict_fhe)
# Neural network models are skipped for this test
# The `fit_benchmark` function of QNNs returns a QAT model and a FP32 model that is similar
# in structure but trained from scratch. Furthermore, the `n_bits` setting
# of the QNN instantiation in `instantiate_model_generic` takes `n_bits` as
# a target accumulator and sets 3-b w&a for these tests. Thus it's
# impossible to reach R-2 of 0.99 when comparing the two NN models returned by `fit_benchmark`
@pytest.mark.parametrize(
"model_class, parameters",
get_sklearn_linear_models_and_datasets()
+ get_sklearn_tree_models_and_datasets()
+ get_sklearn_neighbors_models_and_datasets(),
)
def test_correctness_with_sklearn(
model_class,
parameters,
load_data,
check_r2_score,
check_accuracy,
is_weekly_option,
verbose=True,
):
"""Test that Concrete ML and scikit-learn models are 'equivalent'."""
n_bits = N_BITS_THRESHOLD_FOR_SKLEARN_CORRECTNESS_TESTS
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Run check_correctness_with_sklearn with fhe='disable'")
check_correctness_with_sklearn(
model_class,
x,
y,
n_bits,
check_r2_score,
check_accuracy,
fhe="disable",
)
# Neural network hyper-parameters are not tested
@pytest.mark.parametrize(
"model_class, parameters",
get_sklearn_linear_models_and_datasets()
+ get_sklearn_tree_models_and_datasets()
+ get_sklearn_neighbors_models_and_datasets(),
)
def test_hyper_parameters(
model_class,
parameters,
load_data,
check_r2_score,
check_accuracy,
is_weekly_option,
verbose=True,
):
"""Testing hyper parameters."""
n_bits = N_BITS_THRESHOLD_FOR_SKLEARN_CORRECTNESS_TESTS
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Run check_hyper_parameters")
check_hyper_parameters(
model_class,
n_bits,
x,
y,
check_r2_score,
check_accuracy,
)
@pytest.mark.parametrize("model_class, parameters", MODELS_AND_DATASETS)
@pytest.mark.parametrize("n_bits", [3])
# The complete list of built-in scoring functions can be found in scikit-learn's documentation:
# https://scikit-learn.org/stable/modules/model_evaluation.html#scoring-parameter
# Here, we only consider the main ones
@pytest.mark.parametrize(
"scoring, is_classification",
[
pytest.param("accuracy", True),
pytest.param("balanced_accuracy", True),
pytest.param(make_scorer(top_k_accuracy_score, k=1), True, id="top_k_accuracy"),
pytest.param("average_precision", True),
pytest.param("f1", True),
pytest.param("precision", True),
pytest.param("recall", True),
pytest.param("roc_auc", True),
pytest.param(
make_scorer(matthews_corrcoef, greater_is_better=True),
True,
id="matthews_corrcoef",
),
pytest.param("explained_variance", False),
pytest.param("max_error", False),
pytest.param("neg_mean_absolute_error", False),
pytest.param("neg_mean_squared_error", False),
pytest.param("neg_root_mean_squared_error", False),
pytest.param("neg_median_absolute_error", False),
pytest.param("r2", False),
pytest.param("neg_mean_absolute_percentage_error", False),
],
)
def test_grid_search(
model_class,
parameters,
n_bits,
scoring,
is_classification,
load_data,
is_weekly_option,
verbose=True,
):
"""Test Grid search."""
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
# If the scoring function is meant for classifiers (resp. regressors) but the model is not a
# classifier (resp. regressor), skip the test
# Also skip the test if the classification data is multi-class as most of the scoring functions
# tested here don't support that
if is_classification:
if (
not is_classifier_or_partial_classifier(model_class)
or parameters.get("n_classes", 2) > 2
):
return
elif not is_regressor_or_partial_regressor(model_class):
return
# Max error does not support multi-output models
if scoring == "max_error" and parameters.get("n_targets", 1) > 1:
return
if verbose:
print("Run check_grid_search")
check_grid_search(model_class, x, y, scoring)
@pytest.mark.parametrize("model_class, parameters", MODELS_AND_DATASETS)
@pytest.mark.parametrize("use_dump_method", [True, False])
def test_serialization(
model_class,
parameters,
use_dump_method,
load_data,
is_weekly_option,
default_configuration,
verbose=True,
):
"""Test Serialization."""
n_bits = get_n_bits_non_correctness(model_class)
model, x = preamble(model_class, parameters, n_bits, load_data, is_weekly_option)
# Compile the model to make sure we consider all possible attributes during the serialization
model.compile(x, default_configuration)
if verbose:
print("Run check_serialization")
check_serialization(model, x, use_dump_method)
@pytest.mark.parametrize("model_class, parameters", UNIQUE_MODELS_AND_DATASETS)
@pytest.mark.parametrize(
"n_bits",
N_BITS_WEEKLY_ONLY_BUILDS + N_BITS_REGULAR_BUILDS,
)
def test_double_fit(
model_class,
parameters,
n_bits,
load_data,
is_weekly_option,
verbose=True,
):
"""Test Double fit."""
# Generate a random state for generating the first dataset
random_state = numpy.random.randint(0, 2**15)
parameters["random_state"] = random_state
# Generate two different datasets
x_1, y_1 = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
# Make sure the second dataset is different by using a distinct random state
parameters["random_state"] += 1
x_2, y_2 = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Run check_double_fit")
check_double_fit(model_class, n_bits, x_1, x_2, y_1, y_2)
# Offsets are not supported by XGBoost models
@pytest.mark.parametrize(
"model_class, parameters",
get_sklearn_all_models_and_datasets(ignore="XGB", unique_models=True),
)
@pytest.mark.parametrize(
"n_bits",
N_BITS_WEEKLY_ONLY_BUILDS + N_BITS_REGULAR_BUILDS,
)
def test_offset(
model_class,
parameters,
n_bits,
load_data,
is_weekly_option,
verbose=True,
):
"""Test with offset."""
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Run check_offset")
check_offset(model_class, n_bits, x, y)
@pytest.mark.parametrize("model_class, parameters", UNIQUE_MODELS_AND_DATASETS)
@pytest.mark.parametrize("input_type", ["numpy", "torch", "pandas", "list"])
def test_input_support(
model_class,
parameters,
load_data,
input_type,
default_configuration,
is_weekly_option,
verbose=True,
):
"""Test all models with Pandas, List or Torch inputs."""
n_bits = get_n_bits_non_correctness(model_class)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Run input_support")
check_input_support(model_class, n_bits, default_configuration, x, y, input_type)
@pytest.mark.parametrize("model_class, parameters", MODELS_AND_DATASETS)
def test_inference_methods(
model_class,
parameters,
load_data,
is_weekly_option,
check_float_array_equal,
default_configuration,
verbose=True,
):
"""Test inference methods."""
n_bits = get_n_bits_non_correctness(model_class)
model, x = preamble(model_class, parameters, n_bits, load_data, is_weekly_option)
model.compile(x, default_configuration)
if verbose:
print("Run check_inference_methods")
check_inference_methods(model, model_class, x, check_float_array_equal)
# Pipeline test sometimes fails with RandomForest models. This bug may come from Hummingbird
# and needs further investigations
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/2779
@pytest.mark.parametrize(
"model_class, parameters",
get_sklearn_all_models_and_datasets(ignore="RandomForest"),
)
@pytest.mark.parametrize(
"n_bits",
N_BITS_WEEKLY_ONLY_BUILDS + N_BITS_REGULAR_BUILDS,
)
def test_pipeline(
model_class,
parameters,
n_bits,
load_data,
is_weekly_option,
verbose=True,
):
"""Test with pipelines."""
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Run check_pipeline")
check_pipeline(model_class, x, y)
@pytest.mark.use_gpu
@pytest.mark.parametrize("model_class, parameters", MODELS_AND_DATASETS)
@pytest.mark.parametrize(
"simulate",
[
pytest.param(False, id="fhe"),
pytest.param(True, id="simulate"),
],
)
# N_BITS_LINEAR_MODEL_CRYPTO_PARAMETERS bits is currently the
# limit to find crypto parameters for linear models
# make sure we only compile below that bit-width.
# Additionally, prevent computations in FHE with too many bits
@pytest.mark.parametrize(
"n_bits",
[
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,
)
],
)
# pylint: disable=too-many-branches
def test_predict_correctness(
model_class,
parameters,
simulate,
n_bits,
load_data,
default_configuration,
get_device,
check_is_good_execution_for_cml_vs_circuit,
is_weekly_option,
verbose=True,
):
"""Test prediction correctness between clear quantized and FHE simulation or execution."""
# KNN can only be compiled with small quantization bit numbers for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3979
if n_bits > 5 and get_model_name(model_class) == "KNeighborsClassifier":
pytest.skip("KNeighborsClassifier models can only run with 5 bits at most.")
model, x = preamble(model_class, parameters, n_bits, load_data, is_weekly_option)
# Run the test with more samples during weekly CIs or when using FHE simulation
if is_weekly_option or simulate:
fhe_samples = 5
else:
fhe_samples = 1
if verbose:
print("Compile the model")
model.compile(
x,
default_configuration,
device=get_device,
)
if verbose:
print(f"Check prediction correctness for {fhe_samples} samples.")
# Check prediction correctness between quantized clear and FHE simulation or execution
fhe_test = get_random_samples(x, fhe_samples)
check_is_good_execution_for_cml_vs_circuit(fhe_test, model=model, simulate=simulate)
@pytest.mark.parametrize("model_class, parameters", MODELS_AND_DATASETS)
# pylint: disable=too-many-branches
def test_separated_inference(
model_class,
parameters,
load_data,
default_configuration,
is_weekly_option,
check_float_array_equal,
verbose=True,
):
"""Test prediction correctness between clear quantized and FHE simulation or execution."""
n_bits = min(N_BITS_REGULAR_BUILDS)
# KNN can only be compiled with small quantization bit numbers for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3979
if n_bits > 5 and get_model_name(model_class) == "KNeighborsClassifier":
pytest.skip("KNeighborsClassifier models can only run with 5 bits at most.")
model, x = preamble(model_class, parameters, n_bits, load_data, is_weekly_option)
# Run the test with more samples during weekly CIs or when using FHE simulation
if is_weekly_option:
fhe_samples = 5
else:
fhe_samples = 1
if verbose:
print("Compile the model")
fhe_circuit = model.compile(x, default_configuration)
if verbose:
print("Run check_separated_inference")
# Check that separated inference steps (encrypt, run, decrypt, post_processing, ...) are
# equivalent to built-in methods (predict, predict_proba, ...)
fhe_test = get_random_samples(x, fhe_samples)
check_separated_inference(model, fhe_circuit, fhe_test, check_float_array_equal)
@pytest.mark.parametrize("model_class, parameters", UNIQUE_MODELS_AND_DATASETS)
def test_fitted_compiled_error_raises(
model_class,
parameters,
load_data,
is_weekly_option,
verbose=True,
):
"""Test Fit and Compile error raises."""
n_bits = get_n_bits_non_correctness(model_class)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Run check_fitted_compiled_error_raises")
check_fitted_compiled_error_raises(model_class, n_bits, x, y)
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/4169
@pytest.mark.flaky
@pytest.mark.parametrize("model_class, parameters", MODELS_AND_DATASETS)
@pytest.mark.parametrize(
"error_param, expected_diff",
[({"p_error": 1 - 2**-40}, True), ({"p_error": 2**-40}, False)],
ids=["p_error_high", "p_error_low"],
)
def test_p_error_simulation(
model_class,
parameters,
error_param,
expected_diff,
load_data,
is_weekly_option,
):
"""Test p_error simulation.
The test checks that models compiled with a large p_error value predicts very different results
with simulation or in FHE compared to the expected clear quantized ones.
"""
n_bits = get_n_bits_non_correctness(model_class)
# Get data-set, initialize and fit the model
model, x = preamble(model_class, parameters, n_bits, load_data, is_weekly_option)
# Compile with the specified p_error.
model.compile(x, **error_param)
def check_for_divergent_predictions(
x, model, fhe, max_iterations=N_ALLOWED_FHE_RUN, tolerance=1e-5
):
"""Detect divergence between simulated/FHE execution and clear run."""
# KNeighborsClassifier does not provide a predict_proba method for now
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/3962
predict_function = (
model.predict_proba
if is_classifier_or_partial_classifier(model)
and get_model_name(model) != "KNeighborsClassifier"
else model.predict
)
y_expected = predict_function(x, fhe="disable")
for i in range(max_iterations):
y_pred = predict_function(x[i : i + 1], fhe=fhe).ravel()
if not numpy.allclose(y_pred, y_expected[i : i + 1].ravel(), atol=tolerance):
return True
return False
simulation_diff_found = check_for_divergent_predictions(x, model, fhe="simulate")
fhe_diff_found = check_for_divergent_predictions(x, model, fhe="execute")
# Check if model is linear
is_linear_model = is_model_class_in_a_list(model_class, _get_sklearn_linear_models())
# Skip the following if model is linear
# Simulation and FHE differs with very high p_error on leveled circuit
# FIXME https://github.com/luxfi/concrete-ml-internal/issues/4343
if is_linear_model:
pytest.skip("Skipping test for linear models")
# Check for differences in predictions based on expected_diff
if expected_diff:
assert_msg = (
"With high p_error, predictions should differ in both FHE and simulation."
f" Found differences: FHE={fhe_diff_found}, Simulation={simulation_diff_found}"
)
assert fhe_diff_found and simulation_diff_found, assert_msg
else:
assert_msg = (
"With low p_error, predictions should not differ in FHE or simulation."
f" Found differences: FHE={fhe_diff_found}, Simulation={simulation_diff_found}"
)
assert not (fhe_diff_found or simulation_diff_found), assert_msg
# This test is only relevant for classifier models
@pytest.mark.parametrize(
"model_class, parameters",
get_sklearn_all_models_and_datasets(regressor=False, classifier=True),
)
def test_class_mapping(
model_class,
parameters,
load_data,
is_weekly_option,
verbose=True,
):
"""Test class mapping for classifiers."""
n_bits = min(N_BITS_REGULAR_BUILDS)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
model = instantiate_model_generic(model_class, n_bits=n_bits)
if verbose:
print("Run check_class_mapping")
check_class_mapping(model, x, y)
@pytest.mark.parametrize("model_class, parameters", UNIQUE_MODELS_AND_DATASETS)
def test_exposition_of_sklearn_attributes(
model_class,
parameters,
load_data,
is_weekly_option,
verbose=True,
):
"""Test the exposition of scikit-learn training attributes."""
n_bits = min(N_BITS_REGULAR_BUILDS)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
model = instantiate_model_generic(model_class, n_bits=n_bits)
if verbose:
print("Run check_exposition_of_sklearn_attributes")
check_exposition_of_sklearn_attributes(model, x, y)
@pytest.mark.parametrize(
"model_class, parameters",
get_sklearn_tree_models_and_datasets(select="DecisionTree"),
)
def test_exposition_structural_methods_decision_trees(
model_class,
parameters,
load_data,
is_weekly_option,
verbose=True,
):
"""Test the exposition of specific structural methods found in decision tree models."""
n_bits = min(N_BITS_REGULAR_BUILDS)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
model = instantiate_model_generic(model_class, n_bits=n_bits)
if verbose:
print("Run check_exposition_structural_methods_decision_trees")
check_exposition_structural_methods_decision_trees(model, x, y)
# Importing fitted models only works with linear models
@pytest.mark.parametrize("model_class, parameters", get_sklearn_linear_models_and_datasets())
def test_load_fitted_sklearn_linear_models(
model_class,
parameters,
load_data,
is_weekly_option,
check_float_array_equal,
verbose=True,
):
"""Test that linear models support loading from fitted scikit-learn models."""
n_bits = min(N_BITS_REGULAR_BUILDS)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Run check_load_pre_trained_sklearn_models")
check_load_fitted_sklearn_linear_models(model_class, n_bits, x, y, check_float_array_equal)
# Only circuits from linear models do not have any TLUs
@pytest.mark.parametrize("model_class, parameters", get_sklearn_linear_models_and_datasets())
def test_linear_models_have_no_tlu(
model_class,
parameters,
load_data,
is_weekly_option,
check_circuit_has_no_tlu,
default_configuration,
verbose=True,
):
"""Test that circuits from linear models have no TLUs."""
n_bits = min(N_BITS_REGULAR_BUILDS)
model, x = preamble(model_class, parameters, n_bits, load_data, is_weekly_option)
if verbose:
print("Compile the model")
fhe_circuit = model.compile(x, default_configuration)
if verbose:
print("Run check_circuit_has_no_tlu")
# Check that no TLUs are found within the MLIR
check_circuit_has_no_tlu(fhe_circuit)
# This test does not check rounding at level 2
# Additional tests for this purpose should be added in future updates
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/4179
@pytest.mark.parametrize("model_class, parameters", get_sklearn_tree_models_and_datasets())
@pytest.mark.parametrize("n_bits", [2, 5, 8])
def test_rounding_consistency_for_regular_models(
model_class,
parameters,
n_bits,
load_data,
check_r2_score,
is_weekly_option,
verbose=True,
):
"""Test that Concrete ML without and with rounding are 'equivalent'."""
if verbose:
print("Run check_rounding_consistency")
model = instantiate_model_generic(model_class, n_bits=n_bits)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
# Check `predict_proba` for classifiers
if is_classifier_or_partial_classifier(model):
predict_method = model.predict_proba
metric = check_r2_score
else:
# Check `predict` for regressors
predict_method = model.predict
metric = check_r2_score
check_rounding_consistency(
model,
x,
y,
predict_method,
metric,
)
@pytest.mark.parametrize("model_class, parameters", get_sklearn_tree_models_and_datasets())
@pytest.mark.parametrize("n_bits", [2, 5, 10])
@pytest.mark.parametrize("execute_in_fhe", [True, False])
def test_sum_for_tree_based_models(
model_class,
parameters,
n_bits,
load_data,
is_weekly_option,
execute_in_fhe,
verbose=True,
):
"""Test that the tree ensembles' output are the same with and without the sum in FHE."""
if execute_in_fhe and not is_weekly_option:
pytest.skip("Skipping FHE tests in non-weekly builds")
if verbose:
print("Run check_fhe_sum_for_tree_based_models")
model = instantiate_model_generic(model_class, n_bits=n_bits)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
predict_method = (
model.predict_proba if is_classifier_or_partial_classifier(model) else model.predict
)
check_sum_for_tree_based_models(
model=model,
x=x,
y=y,
predict_method=predict_method,
is_weekly_option=is_weekly_option,
)
# This test should be extended to all built-in models.
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/4234
@pytest.mark.parametrize(
"n_bits, error_message",
[
(0, "n_bits must be a strictly positive integer"),
(-1, "n_bits must be a strictly positive integer"),
({"op_leaves": 2}, "The key 'op_inputs' is mandatory"),
(
{"op_inputs": 4, "op_leaves": 2, "op_weights": 2},
"Invalid keys in 'n_bits' dictionary. Only 'op_inputs' \\(mandatory\\) and 'op_leaves' "
"\\(optional\\) are allowed",
),
(
{"op_inputs": -2, "op_leaves": -5},
"All values in 'n_bits' dictionary must be strictly positive integers",
),
({"op_inputs": 2, "op_leaves": 5}, "'op_leaves' must be less than or equal to 'op_inputs'"),
(0.5, "n_bits must be either an integer or a dictionary"),
],
)
@pytest.mark.parametrize("model_class", _get_sklearn_tree_models())
def test_invalid_n_bits_setting(model_class, n_bits, error_message):
"""Check if the model instantiation raises an exception with invalid `n_bits` settings."""
with pytest.raises(ValueError, match=f"{error_message}. Got '{type(n_bits)}' and '{n_bits}'.*"):
instantiate_model_generic(model_class, n_bits=n_bits)
# This test should be extended to all built-in models.
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/4234
@pytest.mark.parametrize("n_bits", [5, {"op_inputs": 5}, {"op_inputs": 2, "op_leaves": 1}])
@pytest.mark.parametrize("model_class, parameters", get_sklearn_tree_models_and_datasets())
def test_valid_n_bits_setting(
model_class,
n_bits,
parameters,
load_data,
is_weekly_option,
verbose=True,
):
"""Check valid `n_bits` settings."""
if verbose:
print("Run test_valid_n_bits_setting")
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
model = instantiate_model_generic(model_class, n_bits=n_bits)
model.fit(x, y)
# A type error will be raised for NeuralNetworks, which is tested in test_failure_bad_data_types
@pytest.mark.parametrize(
"model_class",
_get_sklearn_linear_models() + _get_sklearn_tree_models() + _get_sklearn_neighbors_models(),
)
@pytest.mark.parametrize(
"bad_value, expected_error",
[
(numpy.nan, "Input X contains NaN."),
(None, "Input X contains NaN."),
("this", "could not convert string to float: 'this'"),
],
)
def test_error_raise_unsupported_pandas_values(model_class, bad_value, expected_error):
"""Test that using Pandas data-frame with unsupported values as input raises correct errors."""
dic = {
"Col One": [1, 2, bad_value, 3],
"Col Two": [4, 5, 6, bad_value],
"Col Three": [bad_value, 7, 8, 9],
}
# Creating a dataframe using dictionary
x_train = pandas.DataFrame(dic)
y_train = x_train["Col Three"]
model = model_class(n_bits=2)
# The error message changed in one of our dependencies
assert sys.version_info.major == 3
if sys.version_info.minor <= 7:
if expected_error == "Input X contains NaN.":
expected_error = "Input contains NaN*"
with pytest.raises(ValueError, match=expected_error):
model.fit(x_train, y_train)
# Add QNNs in this test
# FIXME: https://github.com/luxfi/concrete-ml-internal/issues/4436
@pytest.mark.parametrize(
"model_class, parameters",
get_sklearn_linear_models_and_datasets()
+ get_sklearn_tree_models_and_datasets()
+ get_sklearn_neighbors_models_and_datasets(),
)
def test_initialization_variables_and_defaults_match(
model_class, parameters, load_data, is_weekly_option
):
"""Test CML models init parameters and default values vs scikit-learn models.
Concrete ML currently implements sklearn 1.4 API so skip this test of the
sklearn version differs.
"""
if "1.1." in sklearn.__version__:
pytest.skip(
"Concrete ML currently implements sklearn 1.5 API"
f" skipping this test on version {sklearn.__version__}"
)
n_bits = get_n_bits_non_correctness(model_class)
model_name = get_model_name(model_class)
x, y = get_dataset(model_class, parameters, n_bits, load_data, is_weekly_option)
# Instantiate the model
model = instantiate_model_generic(model_class, n_bits=n_bits)
# Fit the model to create the equivalent sklearn model
model.fit(x, y)
# Assert the sklearn model has been created
assert hasattr(model, "sklearn_model"), "Sklearn model not found"
# Function to retrieve the parameters from any model
# XGBoost init params are in base classes, so we need to gather them recursively
def get_params(model):
"""Get the initializer parameters of the given model."""
cls = get_model_class(model.sklearn_model)
if cls.__name__ in ["XGBClassifier", "XGBRegressor"]:
params = {}
# Recursively gather parameters from all base classes, starting from the current class
def gather_params(c):
# First, recursively gather from base classes so child class can overwrite
for base in c.__bases__:
gather_params(base)
# Update with the current class's parameters
sig = inspect.signature(c)
params.update({k: v.default for k, v in sig.parameters.items()})
gather_params(cls)
return params
# Else, return parameters for non-xgboost models
sig = inspect.signature(cls)
return {k: v.default for k, v in sig.parameters.items()}
# Get the constructor parameters of both the custom and sklearn models
cml_params_defaults = {
k: v.default for k, v in inspect.signature(model.__class__).parameters.items()
}
sklearn_params_defaults = get_params(model)
# If dual is present in sklearn_params_defaults and set to "auto", change it to True
# This handles the difference between sklearn 1.1 (True) and 1.5 ("auto")
if (
model_name in ["LinearSVC", "LinearSVR", "LogisticRegression"]
and sklearn_params_defaults.get("dual") == "auto"
):
sklearn_params_defaults["dual"] = True
# If multi_class is present and set to "deprecated", change it to "auto"
# This handles the difference between sklearn versions for LogisticRegression
if (
model_name == "LogisticRegression"
and sklearn_params_defaults.get("multi_class") == "deprecated"
):
sklearn_params_defaults["multi_class"] = "auto"
# Calculate differences in parameters and defaults
missing_params = set(sklearn_params_defaults.keys()) - set(cml_params_defaults.keys())
extra_params = (set(cml_params_defaults.keys()) - set(sklearn_params_defaults.keys())) - {
"n_bits"
}
# Allow 'fit_encrypted' and 'parameters_range' for SGDClassifier
if model_name == "SGDClassifier":
extra_params -= {"fit_encrypted", "parameters_range"}
def is_nan(x):
"""Check if a variable is nan."""
return isinstance(x, float) and math.isnan(x)
differing_defaults = {
param
for param in sklearn_params_defaults.keys() & cml_params_defaults.keys()
if not (
sklearn_params_defaults[param] == cml_params_defaults[param]
# Some parameter can be nan which can't be compared using equality
or (is_nan(sklearn_params_defaults[param]) and is_nan(cml_params_defaults[param]))
# Some parameters can default to 'warn' in sklearn in order to issue a FutureWraning
# but the actual default is not 'warn', the actula one is given in the docstring
or sklearn_params_defaults[param] == "warn"
)
}
# Remove expected different params defaults from differing_defaults
expected_differences = EXPECTED_DIFFERENT_DEFAULT_PARAMETERS.get(model_name, {})
# For mypy
assert isinstance(expected_differences, dict)
differing_defaults.difference_update(expected_differences.keys())
# Assert parameter exist and matching defaults
assert not missing_params, f"{model_name} is missing these init parameters: {missing_params}"
assert not extra_params, f"{model_name} has extra init parameters: {extra_params}"
assert not differing_defaults, (
f"Default values do not match for: {differing_defaults}. "
f"Expected: {[sklearn_params_defaults[param] for param in differing_defaults]}, "
f"Found: {[cml_params_defaults[param] for param in differing_defaults]}"
)
@pytest.mark.parametrize("model_class", _get_sklearn_tree_models())
@pytest.mark.parametrize(
"param, error_message",
[
({"eval_metric": lambda x: x}, "Callable eval_metric is not supported for serialization"),
({"kwargs": {"extra": "param"}}, "kwargs are not supported for serialization"),
({"callbacks": [lambda x: x]}, "callbacks are not supported for serialization"),
],
)
def test_xgb_serialization_errors(model_class, param, error_message):
"""Test that XGBoost models with unsupported parameters raise errors on serialization."""
model_name = get_model_name(model_class)
if model_name in ["XGBClassifier", "XGBRegressor"]:
with pytest.raises(NotImplementedError, match=error_message):
model = instantiate_model_generic(model_class, 5, **param)
model.dumps()
@pytest.mark.use_gpu
@pytest.mark.flaky
@pytest.mark.parametrize(
"model_class, parameters", get_sklearn_tree_models_and_datasets(True, True)
)
@pytest.mark.parametrize("n_bits", [4, 8, 12])
def test_tfhers_inputs_outputs_trees(model_class, parameters, n_bits, load_data, get_device):
"""Check that 8b tree-based classifiers work with TFHE-rs inputs/outputs."""
x, y = get_dataset(model_class, parameters, n_bits, load_data, True)
# Use a single example in FHE to make the test fast enough for the CI
fhe_test_data = x[0:1, :]
model = instantiate_model_generic(model_class, n_bits=n_bits)
# Fit the model to create the equivalent sklearn model
model.fit(x, y)
# If the model is not supported or if the n_bits is not supported
# an error is raised
if not n_bits == 8 or is_regressor_or_partial_regressor(model_class):
with pytest.raises(AssertionError, match=".*supported for 8-bit tree-based.*"):
model.compile(x, ciphertext_format=CiphertextFormat.TFHE_RS, device=get_device)
return
# Check that we can first compile to Concrete, then to
# TFHE-rs input/outputs then to concrete again
model.compile(x, device=get_device)
y_pred_concrete = model.predict(fhe_test_data, fhe="execute")
model.compile(x, ciphertext_format=CiphertextFormat.TFHE_RS, device=get_device)
with pytest.raises(ValueError, match="Simulation with TFHE-rs ciphertext.*"):
model.predict(fhe_test_data, fhe="simulate")
# Run the model in FHE for TFHE-rs inputs/outputs
y_pred_tfhers = model.predict(fhe_test_data, fhe="execute")
model.compile(x, device=get_device)
# Check correctness with TFHE-rs inputs/outputs
assert numpy.all(y_pred_tfhers == y_pred_concrete)
@pytest.mark.parametrize(
"model_class, parameters", get_sklearn_tree_models_and_datasets(True, True)
)
def test_tfhers_trees_non_8b_not_working(model_class, parameters, load_data):
"""Check that non-supported configs for tree models for TFHE-rs inputs raise an exception."""
n_bits = 4
x, y = get_dataset(model_class, parameters, n_bits, load_data, True)
model = instantiate_model_generic(model_class, n_bits=n_bits)
model.fit(x, y)
with pytest.raises(AssertionError, match=".*supported for 8-bit tree-based.*"):
model.compile(x, ciphertext_format=CiphertextFormat.TFHE_RS)
@pytest.mark.parametrize(
"model_class, parameters",
get_sklearn_linear_models_and_datasets() + get_sklearn_neural_net_models_and_datasets(),
)
def test_tfhers_cml_models_not_working(model_class, parameters, load_data):
"""Check that models that don't support TFHE-rs inputs raise an exception."""
n_bits = 8
x, y = get_dataset(model_class, parameters, n_bits, load_data, True)
model = instantiate_model_generic(model_class, n_bits=n_bits)
model.fit(x, y)
with pytest.raises(AssertionError, match=".*supported for 8-bit tree-based.*"):
model.compile(x, ciphertext_format=CiphertextFormat.TFHE_RS)