From b24107ff2aeba3df45ae8a240f9fd25098aa3ef4 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:25:37 +0100 Subject: [PATCH 01/15] Stop truncating log-odds in generate_data log_odd was an integer array, so each float coefficient added to it was silently truncated toward zero and the drawn labels followed the wrong probabilities. Accumulate in a float array instead. --- glmdisc/_generateData.py | 2 +- tests/tests_SEM/test_SEM_generate.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/glmdisc/_generateData.py b/glmdisc/_generateData.py index 06bc84e..c24ee52 100644 --- a/glmdisc/_generateData.py +++ b/glmdisc/_generateData.py @@ -36,7 +36,7 @@ def generate_data(n, d, theta=None, plot=False): for i in range(d): xd[:, i] = pd.cut(x[:, i], bins=cuts, labels=[0, 1, 2]) - log_odd = np.array([0] * n) + log_odd = np.zeros(n) for i in range(n): for j in range(d): log_odd[i] += theta[int(xd[i, j]), j] diff --git a/tests/tests_SEM/test_SEM_generate.py b/tests/tests_SEM/test_SEM_generate.py index c270c16..e062384 100644 --- a/tests/tests_SEM/test_SEM_generate.py +++ b/tests/tests_SEM/test_SEM_generate.py @@ -34,3 +34,20 @@ def test_generate_theta_error(): with pytest.raises(ValueError): glmdisc.Glmdisc.generate_data(n=800, d=d, theta=theta) + + +def test_generate_log_odd_not_truncated(monkeypatch): + theta = np.array([[0.4], [0.9], [-0.6]]) + drawn = {} + + def fake_binomial(n, p, size=None): + drawn["p"] = p + return np.zeros(len(p), dtype=int) + + monkeypatch.setattr(np.random, "binomial", fake_binomial) + np.random.seed(0) + x, y, theta2 = glmdisc.Glmdisc.generate_data(n=50, d=1, theta=theta) + + levels = np.digitize(x[:, 0], [0.333, 0.666]) + expected = 1 / (1 + np.exp(-theta[levels, 0])) + np.testing.assert_allclose(drawn["p"], expected) From 44a72e6cbc29fdcb44bce025bf10457750b7abb7 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:25:42 +0100 Subject: [PATCH 02/15] Pass an explicit size to binomial in generate_data Draw exactly one label per observation instead of relying on the shape of the probability vector to set the output size. --- glmdisc/_generateData.py | 2 +- tests/tests_SEM/test_SEM_generate.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/glmdisc/_generateData.py b/glmdisc/_generateData.py index c24ee52..5a31f10 100644 --- a/glmdisc/_generateData.py +++ b/glmdisc/_generateData.py @@ -42,7 +42,7 @@ def generate_data(n, d, theta=None, plot=False): log_odd[i] += theta[int(xd[i, j]), j] p = 1 / (1 + np.exp(- log_odd)) - y = np.random.binomial(1, p) + y = np.random.binomial(1, p, size=n) if plot and d > 1: for unique_pair_of_feature_as_tuple in combinations(range(d), 2): diff --git a/tests/tests_SEM/test_SEM_generate.py b/tests/tests_SEM/test_SEM_generate.py index e062384..f701167 100644 --- a/tests/tests_SEM/test_SEM_generate.py +++ b/tests/tests_SEM/test_SEM_generate.py @@ -51,3 +51,17 @@ def fake_binomial(n, p, size=None): levels = np.digitize(x[:, 0], [0.333, 0.666]) expected = 1 / (1 + np.exp(-theta[levels, 0])) np.testing.assert_allclose(drawn["p"], expected) + + +def test_generate_labels_size(monkeypatch): + sizes = [] + binomial = np.random.binomial + + def spy_binomial(n, p, size=None): + sizes.append(size) + return binomial(n, p, size=size) + + monkeypatch.setattr(np.random, "binomial", spy_binomial) + x, y, theta = glmdisc.Glmdisc.generate_data(n=1, d=3) + assert sizes == [1] + assert y.shape == (1,) From 1bf025d1b95e3017f57ee861f02d8839d9ae7851 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:26:02 +0100 Subject: [PATCH 03/15] Plot each feature pair on its own figure generate_data(plot=True) drew every pair of features on the same axes, so the scatterplots overlapped into an unreadable cloud. Open a figure per pair, label its axes and show it without blocking, as the plot method does. --- glmdisc/_generateData.py | 11 ++++++----- tests/tests_SEM/test_SEM_generate.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/glmdisc/_generateData.py b/glmdisc/_generateData.py index 5a31f10..619e680 100644 --- a/glmdisc/_generateData.py +++ b/glmdisc/_generateData.py @@ -45,10 +45,11 @@ def generate_data(n, d, theta=None, plot=False): y = np.random.binomial(1, p, size=n) if plot and d > 1: - for unique_pair_of_feature_as_tuple in combinations(range(d), 2): - plt.scatter(x[:, unique_pair_of_feature_as_tuple[0]], - x[:, unique_pair_of_feature_as_tuple[1]], - c=y, - s=[1 for _ in range(len(x))]) + for j1, j2 in combinations(range(d), 2): + plt.figure() + plt.scatter(x[:, j1], x[:, j2], c=y, s=1) + plt.xlabel("x" + str(j1)) + plt.ylabel("x" + str(j2)) + plt.show(block=False) return [x, y, theta] diff --git a/tests/tests_SEM/test_SEM_generate.py b/tests/tests_SEM/test_SEM_generate.py index f701167..da75a35 100644 --- a/tests/tests_SEM/test_SEM_generate.py +++ b/tests/tests_SEM/test_SEM_generate.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import matplotlib.pyplot as plt import pytest import numpy as np import glmdisc @@ -65,3 +66,13 @@ def spy_binomial(n, p, size=None): x, y, theta = glmdisc.Glmdisc.generate_data(n=1, d=3) assert sizes == [1] assert y.shape == (1,) + + +def test_generate_plot_one_figure_per_pair(): + plt.close('all') + glmdisc.Glmdisc.generate_data(n=50, d=3, plot=True) + assert len(plt.get_fignums()) == 3 + for num in plt.get_fignums(): + ax = plt.figure(num).axes[0] + assert len(ax.collections) == 1 + plt.close('all') From 289e9c8e37bc8ecf704bbd76bf619658ce006af3 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:26:28 +0100 Subject: [PATCH 04/15] Replace np.float in gini_utils for NumPy 2 The np.float alias was removed in NumPy 1.24, so every call to gini raised AttributeError. Use the builtin float, and add the first tests of the Gini index and its DeLong variance against scikit-learn's AUC. --- glmdisc/_gini_utils.py | 20 ++++++++++---------- tests/test_gini.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 tests/test_gini.py diff --git a/glmdisc/_gini_utils.py b/glmdisc/_gini_utils.py index 78bd017..f3ec0ed 100644 --- a/glmdisc/_gini_utils.py +++ b/glmdisc/_gini_utils.py @@ -24,7 +24,7 @@ def compute_midrank(x): J = np.argsort(x) Z = x[J] N = len(x) - T = np.zeros(N, dtype=np.float) + T = np.zeros(N, dtype=float) i = 0 while i < N: j = i @@ -32,7 +32,7 @@ def compute_midrank(x): j += 1 T[i:j] = 0.5 * (i + j - 1) i = j - T2 = np.empty(N, dtype=np.float) + T2 = np.empty(N, dtype=float) # Note(kazeevn) +1 is due to Python using 0-based indexing # instead of 1-based in the AUC formula in the paper T2[J] = T + 1 @@ -50,7 +50,7 @@ def compute_midrank_weight(x, sample_weight): Z = x[J] cumulative_weight = np.cumsum(sample_weight[J]) N = len(x) - T = np.zeros(N, dtype=np.float) + T = np.zeros(N, dtype=float) i = 0 while i < N: j = i @@ -58,7 +58,7 @@ def compute_midrank_weight(x, sample_weight): j += 1 T[i:j] = cumulative_weight[i:j].mean() i = j - T2 = np.empty(N, dtype=np.float) + T2 = np.empty(N, dtype=float) T2[J] = T return T2 @@ -99,9 +99,9 @@ def fast_de_long_weights(predictions_sorted_transposed, label_1_count, sample_we negative_examples = predictions_sorted_transposed[:, m:] k = predictions_sorted_transposed.shape[0] - tx = np.empty([k, m], dtype=np.float) - ty = np.empty([k, n], dtype=np.float) - tz = np.empty([k, m + n], dtype=np.float) + tx = np.empty([k, m], dtype=float) + ty = np.empty([k, n], dtype=float) + tz = np.empty([k, m + n], dtype=float) for r in range(k): tx[r, :] = compute_midrank_weight(positive_examples[r, :], sample_weight[:m]) ty[r, :] = compute_midrank_weight(negative_examples[r, :], sample_weight[m:]) @@ -149,9 +149,9 @@ def fast_de_long_no_weights(predictions_sorted_transposed, label_1_count): negative_examples = predictions_sorted_transposed[:, m:] k = predictions_sorted_transposed.shape[0] - tx = np.empty([k, m], dtype=np.float) - ty = np.empty([k, n], dtype=np.float) - tz = np.empty([k, m + n], dtype=np.float) + tx = np.empty([k, m], dtype=float) + ty = np.empty([k, n], dtype=float) + tz = np.empty([k, m + n], dtype=float) for r in range(k): tx[r, :] = compute_midrank(positive_examples[r, :]) ty[r, :] = compute_midrank(negative_examples[r, :]) diff --git a/tests/test_gini.py b/tests/test_gini.py new file mode 100644 index 0000000..939940a --- /dev/null +++ b/tests/test_gini.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import numpy as np +import pytest +from sklearn.metrics import roc_auc_score + +import glmdisc + + +def _scores(n=200, seed=0): + rng = np.random.RandomState(seed) + y = rng.binomial(1, 0.4, size=n) + p = np.clip(0.3 * y + rng.uniform(size=n) * 0.7, 0, 1) + return y, p + + +def test_gini_matches_auc(): + y, p = _scores() + value, ci = glmdisc.gini(y, p) + assert value == pytest.approx(2 * roc_auc_score(y, p) - 1) + assert ci[0] < value < ci[1] <= 1 + + +def test_gini_with_ties(): + y, p = _scores() + p = np.round(p, 1) + value, _ = glmdisc.gini(y, p) + assert value == pytest.approx(2 * roc_auc_score(y, p) - 1) + + +def test_gini_with_sample_weight(): + y, p = _scores() + w = np.random.RandomState(1).uniform(0.5, 2, size=len(y)) + value, _ = glmdisc.gini(y, p, sample_weight=w) + assert value == pytest.approx(2 * roc_auc_score(y, p, sample_weight=w) - 1) From 2da330ac0df62b550736894dbb79c01026b271a2 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:26:46 +0100 Subject: [PATCH 05/15] Fix the level check in gini "not level < 1 and level > 0" parses as "(level >= 1) and (level > 0)", so 0 and negative levels were accepted and produced NaN intervals. Test that the level lies strictly between 0 and 1 instead. --- glmdisc/_gini_utils.py | 2 +- tests/test_gini.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/glmdisc/_gini_utils.py b/glmdisc/_gini_utils.py index f3ec0ed..f1de898 100644 --- a/glmdisc/_gini_utils.py +++ b/glmdisc/_gini_utils.py @@ -201,7 +201,7 @@ def gini(ground_truth, predictions, sample_weight=None, level=.95): sample_weight: np.array of floats of the sample weights level: float (between 0 and 1) describing the level required for the confidence interval """ - if (not isinstance(level, float)) or (not level < 1 and level > 0): + if not isinstance(level, float) or not 0 < level < 1: raise ValueError("level should be a single float between 0 and 1") auc, auc_cov = delong_roc_variance(ground_truth, predictions, sample_weight) auc_std = np.sqrt(auc_cov) diff --git a/tests/test_gini.py b/tests/test_gini.py index 939940a..64dbd80 100644 --- a/tests/test_gini.py +++ b/tests/test_gini.py @@ -33,3 +33,18 @@ def test_gini_with_sample_weight(): w = np.random.RandomState(1).uniform(0.5, 2, size=len(y)) value, _ = glmdisc.gini(y, p, sample_weight=w) assert value == pytest.approx(2 * roc_auc_score(y, p, sample_weight=w) - 1) + + +@pytest.mark.parametrize("level", [0., -.5, 1., 1.5, 1, "0.95", None]) +def test_gini_invalid_level(level): + y, p = _scores() + with pytest.raises(ValueError): + glmdisc.gini(y, p, level=level) + + +def test_gini_level_widens_interval(): + y, p = _scores() + _, ci_90 = glmdisc.gini(y, p, level=.9) + _, ci_99 = glmdisc.gini(y, p, level=.99) + assert ci_99[0] < ci_90[0] + assert ci_99[1] >= ci_90[1] From 6da9a97e5a57e8117fae8ffc6e051058a61dd8bb Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:27:28 +0100 Subject: [PATCH 06/15] Guard the cut-point deletion in best_formula When a continuous feature had a quantization level made only of missing values, its lower bound was NaN, nanmin matched nothing and indexing the empty result raised IndexError. Ignore NaN bounds and only drop the smallest one when some remain. --- glmdisc/_bestFormula.py | 11 ++++++++--- tests/tests_SEM/test_SEM_best_formula.py | 25 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/glmdisc/_bestFormula.py b/glmdisc/_bestFormula.py index 130736b..bb8ea1c 100644 --- a/glmdisc/_bestFormula.py +++ b/glmdisc/_bestFormula.py @@ -16,9 +16,14 @@ def _best_formula_cont_sem(self, emap_best): for j in range(self.d_cont): best_disc.append([]) for k in np.unique(emap_best[:, j]): - best_disc[j].append( - np.nanmin(self.predictors_cont[emap_best[:, j] == k, j])) - del best_disc[j][np.where(best_disc[j] == np.nanmin(best_disc[j]))[0][0]] + lower_bound = self.predictors_cont[emap_best[:, j] == k, j] + lower_bound = lower_bound[~np.isnan(lower_bound)] + # the level reserved for missing values has no lower bound + if lower_bound.size > 0: + best_disc[j].append(lower_bound.min()) + # the smallest lower bound is not a cut-point + if best_disc[j]: + del best_disc[j][np.argmin(best_disc[j])] if len(best_disc[j]) < 1: logger.info("No cut-points found for continuous variable " + str(j) + ".") else: diff --git a/tests/tests_SEM/test_SEM_best_formula.py b/tests/tests_SEM/test_SEM_best_formula.py index 52e1508..46b02f9 100644 --- a/tests/tests_SEM/test_SEM_best_formula.py +++ b/tests/tests_SEM/test_SEM_best_formula.py @@ -3,6 +3,7 @@ import numpy as np import pandas as pd import glmdisc +from glmdisc._bestFormula import _best_formula_cont_sem def test_best_formula(caplog): @@ -45,3 +46,27 @@ def test_best_formula_no_cont(): model = glmdisc.Glmdisc(validation=False, test=False) model.fit(predictors_cont=None, predictors_qual=xd, labels=y, iter=11) model.best_formula() + + +class _Stub: + d_cont = 2 + + +def test_best_formula_cont_sem_missing_values(): + stub = _Stub() + stub.predictors_cont = np.array([[1., np.nan], + [2., np.nan], + [3., np.nan], + [4., np.nan]]) + emap_best = np.array([[0, 0], [0, 0], [1, 0], [1, 0]]) + assert _best_formula_cont_sem(stub, emap_best) == [[3.], []] + + +def test_best_formula_cont_sem_missing_level(): + stub = _Stub() + stub.predictors_cont = np.array([[1., 5.], + [2., 6.], + [3., np.nan], + [np.nan, 7.]]) + emap_best = np.array([[0, 0], [0, 0], [1, 1], [2, 1]]) + assert _best_formula_cont_sem(stub, emap_best) == [[3.], [7.]] From a4eb14d6bd902752f96ae07e82729a17af47e18a Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:28:25 +0100 Subject: [PATCH 07/15] Name predictors_qual in discretize shape errors The error raised when the number of categorical columns does not match the fitted model talked about predictors_cont, pointing users at the wrong argument. Fix the message for both the SEM and NN checks. --- glmdisc/_discretize.py | 4 ++-- tests/tests_SEM/test_SEM_discretize.py | 32 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/glmdisc/_discretize.py b/glmdisc/_discretize.py index 86ca867..c6e9c05 100644 --- a/glmdisc/_discretize.py +++ b/glmdisc/_discretize.py @@ -36,7 +36,7 @@ def _check_args_discretize_nn(self, predictors_cont, predictors_qual): raise ValueError(msg) if d_2 != self.d_qual: msg = ('Shape of ' + str(d_2) + - ' for predictors_cont does not match training set of size ' + str(self.d_qual) + '.') + ' for predictors_qual does not match training set of size ' + str(self.d_qual) + '.') logger.error(msg) raise ValueError(msg) @@ -70,7 +70,7 @@ def _check_args_discretize_sem(self, predictors_cont, predictors_qual): raise ValueError(msg) if d_2 != sum(d_2bis) or d_2 != self.d_qual: msg = ('Shape of ' + str(d_2) + - ' for predictors_cont does not match provided link function ' + ' for predictors_qual does not match provided link function ' 'of size ' + str(sum(d_2bis)) + ' and/or training set of size ' + str(self.d_qual) + '.') logger.error(msg) raise ValueError(msg) diff --git a/tests/tests_SEM/test_SEM_discretize.py b/tests/tests_SEM/test_SEM_discretize.py index 0fac42a..a941a98 100644 --- a/tests/tests_SEM/test_SEM_discretize.py +++ b/tests/tests_SEM/test_SEM_discretize.py @@ -5,6 +5,7 @@ import pytest import random import glmdisc +from glmdisc._discretize import _check_args_discretize_nn def test_discretize_new(): @@ -67,3 +68,34 @@ def test_discretize_wrong(): model.discretize(predictors_cont=None, predictors_qual=xd) with pytest.raises(ValueError): model.discretize(predictors_cont=x, predictors_qual=xd) + + +def test_discretize_wrong_messages(caplog): + n = 100 + d = 2 + x, y, theta = glmdisc.Glmdisc.generate_data(n, d) + model = glmdisc.Glmdisc(validation=False, test=False) + random.seed(1) + np.random.seed(1) + model.fit(predictors_cont=x, predictors_qual=None, labels=y, iter=11) + with pytest.raises(ValueError): + model.discretize(predictors_cont=x, predictors_qual=x.astype(str)) + assert "Shape of 2 for predictors_qual" in caplog.records[-1].message + with pytest.raises(ValueError): + model.discretize(predictors_cont=x[:, :1], predictors_qual=None) + assert "Shape of 1 for predictors_cont" in caplog.records[-1].message + + +class _Stub: + d_cont = 1 + d_qual = 1 + + +def test_check_args_discretize_nn_messages(caplog): + x = np.zeros((5, 1)) + with pytest.raises(ValueError): + _check_args_discretize_nn(_Stub(), x, np.zeros((5, 3))) + assert "Shape of 3 for predictors_qual" in caplog.records[-1].message + with pytest.raises(ValueError): + _check_args_discretize_nn(_Stub(), np.zeros((5, 2)), np.zeros((5, 1))) + assert "Shape of 2 for predictors_cont" in caplog.records[-1].message From 97b6d74feba600eceb7c9d95e976a8d692944601 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:29:09 +0100 Subject: [PATCH 08/15] Raise NotFittedError on an unfitted SEM model best_reglog is None before fit, which scikit-learn's check_is_fitted rejects with a TypeError, so predict and discretize on a new instance did not raise the documented NotFittedError. Test for None first. --- glmdisc/__init__.py | 2 ++ tests/test_init.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/glmdisc/__init__.py b/glmdisc/__init__.py index b65229e..5733dd6 100644 --- a/glmdisc/__init__.py +++ b/glmdisc/__init__.py @@ -266,6 +266,8 @@ def _check_is_fitted(self): """ if self.algorithm == "SEM": try: + if self.best_reglog is None: + raise sk.exceptions.NotFittedError("This Glmdisc instance is not fitted yet.") sk.utils.validation.check_is_fitted(self.best_reglog) for link in self.best_link: if isinstance(link, sk.linear_model.LogisticRegression): diff --git a/tests/test_init.py b/tests/test_init.py index ff574ab..43292c7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -83,3 +83,11 @@ def test_validation_criterion(caplog): assert caplog.records[0].message == ('No need to penalize the log-likelihood when a validation set is used. ' 'Using log-likelihood instead.') + + +def test_not_fitted_sem(): + model = glmdisc.Glmdisc() + with pytest.raises(glmdisc.NotFittedError): + model._check_is_fitted() + with pytest.raises(glmdisc.NotFittedError): + model.predict(np.zeros((3, 1)), None) From 63ea1935b10c12a72de26436028ed11c1d87420d Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:29:18 +0100 Subject: [PATCH 09/15] Validate m_start and burn_in as integers m_start=2.5 passed the range check and crashed later in range(), and burn_in was never checked. Require integers, and a non-negative burn_in. --- glmdisc/__init__.py | 10 ++++++++-- tests/test_init.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/glmdisc/__init__.py b/glmdisc/__init__.py index 5733dd6..7accb6e 100644 --- a/glmdisc/__init__.py +++ b/glmdisc/__init__.py @@ -215,8 +215,14 @@ def __init__(self, algorithm="SEM", test=True, validation=True, criterion="bic", raise ValueError(msg) # m_start doit être pas déconnant - if not 2 <= m_start <= 50: - msg = 'Please set 2 <= m_start <= 50' + if not isinstance(m_start, (int, np.integer)) or not 2 <= m_start <= 50: + msg = 'Please set an integer 2 <= m_start <= 50' + logger.error(msg) + raise ValueError(msg) + + # burn_in must be a non-negative integer + if not isinstance(burn_in, (int, np.integer)) or isinstance(burn_in, bool) or burn_in < 0: + msg = 'Please set an integer burn_in >= 0' logger.error(msg) raise ValueError(msg) diff --git a/tests/test_init.py b/tests/test_init.py index 43292c7..90abe26 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -64,6 +64,17 @@ def test_m_start(): with pytest.raises(ValueError): glmdisc.Glmdisc(m_start=1) + with pytest.raises(ValueError): + glmdisc.Glmdisc(m_start=2.5) + + +def test_burn_in(): + assert glmdisc.Glmdisc(burn_in=0).burn_in == 0 + + for burn_in in [-1, 2.5, "5", None, True]: + with pytest.raises(ValueError): + glmdisc.Glmdisc(burn_in=burn_in) + def test_gini_penalized(caplog): glmdisc.Glmdisc(validation=False, From 17456f2473daaf8ae52a847bc70afaa8e0548189 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:30:38 +0100 Subject: [PATCH 10/15] Support plot(logodd) with only one feature type The GAM input was built from both predictors_cont and predictors_qual unconditionally, so a model fitted on continuous features only, or on categorical ones only, crashed on the missing block. Build the design from the blocks that exist. --- glmdisc/_plot.py | 24 +++++++++++++++--------- tests/tests_SEM/test_SEM_plot.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/glmdisc/_plot.py b/glmdisc/_plot.py index 39e4b4d..429c38b 100644 --- a/glmdisc/_plot.py +++ b/glmdisc/_plot.py @@ -50,17 +50,23 @@ def _plot_disc(self, predictors_cont_number, predictors_qual_number): def _plot_logodd(self): - # Gérer les manquants dans le GAM - lignes_completes = np.invert( - np.isnan(self.predictors_cont).sum(axis=1).astype(bool)) + # Handle missing values in the GAM: keep the complete rows of the continuous features + if self.d_cont > 0: + lignes_completes = np.invert(np.isnan(self.predictors_cont).any(axis=1)) + else: + lignes_completes = np.ones(self.n, dtype=bool) + + features = [] + if self.d_cont > 0: + features.append(pd.DataFrame(self.predictors_cont[lignes_completes, :]).apply( + lambda x: x.astype('float'))) + if self.d_qual > 0: + features.append(pd.DataFrame(self.predictors_qual[lignes_completes, :]).apply( + lambda x: x.astype('category'))) - # Fit du GAM sur tout le monde + # Fit the GAM on everyone gam = LogisticGAM(dtype=['numerical' for _ in range(self.d_cont)] + ['categorical' for _ in range( - self.d_qual)]).fit( - pd.concat([pd.DataFrame(self.predictors_cont[lignes_completes, :]).apply( - lambda x: x.astype('float')), - pd.DataFrame(self.predictors_qual[lignes_completes, :]).apply( - lambda x: x.astype('category'))], axis=1), self.labels[lignes_completes]) + self.d_qual)]).fit(pd.concat(features, axis=1), self.labels[lignes_completes]) # Quelles que soient les valeurs de predictors_cont_number et # predictors_qual_number, on plot tout pour l'instant diff --git a/tests/tests_SEM/test_SEM_plot.py b/tests/tests_SEM/test_SEM_plot.py index 564eece..80d7b70 100644 --- a/tests/tests_SEM/test_SEM_plot.py +++ b/tests/tests_SEM/test_SEM_plot.py @@ -56,3 +56,25 @@ def test_plot_log(caplog): assert caplog.records[-1].message == ("A single int (more than 0 and less than the " "number of columns in predictors_cont) must be " "provided for predictors_cont_number") + + +def _digitized(x): + return np.digitize(x, [0.333, 0.666]).astype(float) + + +def test_plot_logodd_cont_only(): + np.random.seed(1) + x, y, theta = glmdisc.Glmdisc.generate_data(300, 2) + model = glmdisc.Glmdisc(validation=False, test=False) + model.fit(predictors_cont=x, predictors_qual=None, labels=y, iter=11) + model.plot(plot_type="logodd") + plt.close('all') + + +def test_plot_logodd_qual_only(): + np.random.seed(1) + x, y, theta = glmdisc.Glmdisc.generate_data(300, 2) + model = glmdisc.Glmdisc(validation=False, test=False) + model.fit(predictors_cont=None, predictors_qual=_digitized(x), labels=y, iter=11) + model.plot(plot_type="logodd") + plt.close('all') From 6216ed972f827b55807955f600f952ed6b55e499 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:31:12 +0100 Subject: [PATCH 11/15] Fix plot(logodd) crash with a single feature plt.subplots returns a bare Axes rather than an array when there is a single subplot, so enumerate(axs) raised TypeError. Ask for a 2D array of axes. --- glmdisc/_plot.py | 5 +++-- tests/tests_SEM/test_SEM_plot.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/glmdisc/_plot.py b/glmdisc/_plot.py index 429c38b..f6e4ca3 100644 --- a/glmdisc/_plot.py +++ b/glmdisc/_plot.py @@ -71,9 +71,10 @@ def _plot_logodd(self): # Quelles que soient les valeurs de predictors_cont_number et # predictors_qual_number, on plot tout pour l'instant plt.figure() - fig, axs = plt.subplots(1, self.d_cont + self.d_qual) + # squeeze=False keeps axs a 2D array even with a single feature + fig, axs = plt.subplots(1, self.d_cont + self.d_qual, squeeze=False) plt.rcParams['figure.figsize'] = (28, 8) - for i, ax in enumerate(axs): + for i, ax in enumerate(axs[0]): try: XX = gam.generate_X_grid(term=i) ax.plot(XX[:, i], gam.partial_dependence(term=i, X=XX)) diff --git a/tests/tests_SEM/test_SEM_plot.py b/tests/tests_SEM/test_SEM_plot.py index 80d7b70..59b9f68 100644 --- a/tests/tests_SEM/test_SEM_plot.py +++ b/tests/tests_SEM/test_SEM_plot.py @@ -78,3 +78,17 @@ def test_plot_logodd_qual_only(): model.fit(predictors_cont=None, predictors_qual=_digitized(x), labels=y, iter=11) model.plot(plot_type="logodd") plt.close('all') + + +def test_plot_logodd_single_feature(): + np.random.seed(1) + x, y, theta = glmdisc.Glmdisc.generate_data(300, 2) + model = glmdisc.Glmdisc(validation=False, test=False) + model.fit(predictors_cont=x[:, :1], predictors_qual=None, labels=y, iter=11) + model.plot(plot_type="logodd") + plt.close('all') + + model = glmdisc.Glmdisc(validation=False, test=False) + model.fit(predictors_cont=None, predictors_qual=_digitized(x[:, :1]), labels=y, iter=11) + model.plot(plot_type="logodd") + plt.close('all') From cb983dc32fc8472a732b55ff25162e376e77d2e8 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:31:16 +0100 Subject: [PATCH 12/15] Translate the remaining French comment in plot --- glmdisc/_plot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/glmdisc/_plot.py b/glmdisc/_plot.py index f6e4ca3..f2a939d 100644 --- a/glmdisc/_plot.py +++ b/glmdisc/_plot.py @@ -68,8 +68,8 @@ def _plot_logodd(self): gam = LogisticGAM(dtype=['numerical' for _ in range(self.d_cont)] + ['categorical' for _ in range( self.d_qual)]).fit(pd.concat(features, axis=1), self.labels[lignes_completes]) - # Quelles que soient les valeurs de predictors_cont_number et - # predictors_qual_number, on plot tout pour l'instant + # Whatever the values of predictors_cont_number and + # predictors_qual_number, everything is plotted for now plt.figure() # squeeze=False keeps axs a 2D array even with a single feature fig, axs = plt.subplots(1, self.d_cont + self.d_qual, squeeze=False) From beabbaaa782fe6492c9e2b8a77c16c31a6487cca Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:32:03 +0100 Subject: [PATCH 13/15] Fit each LabelEncoder once in _init_disc The encoder of every categorical feature was fitted four times per feature to recompute its number of levels, in addition to the fit that is stored in affectations. Read the level count from the stored encoder. --- glmdisc/_fit.py | 12 ++++------- tests/tests_SEM/test_SEM_init_disc.py | 30 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 tests/tests_SEM/test_SEM_init_disc.py diff --git a/glmdisc/_fit.py b/glmdisc/_fit.py index 3f36c7c..f2754c7 100644 --- a/glmdisc/_fit.py +++ b/glmdisc/_fit.py @@ -5,7 +5,6 @@ import numpy as np import sklearn as sk from loguru import logger -from scipy import stats from glmdisc._fitNN import _fit_nn from glmdisc._fitSEM import _fit_sem @@ -105,13 +104,10 @@ def _init_disc(self, continu_complete_case): for j in range(self.d_qual): self.affectations[j + self.d_cont] = sk.preprocessing.LabelEncoder().fit(self.predictors_qual[:, j]) - if (self.m_start > stats.describe(sk.preprocessing.LabelEncoder().fit(self.predictors_qual[:, j]).transform( - self.predictors_qual[:, j])).minmax[1] + 1): - edisc[:, j + self.d_cont] = np.random.choice(list(range( - stats.describe(sk.preprocessing.LabelEncoder().fit( - self.predictors_qual[:, j]).transform( - self.predictors_qual[:, j])).minmax[1])), - size=self.n) + # the last label of the (already fitted) encoder is the number of levels minus one + max_label = len(self.affectations[j + self.d_cont].classes_) - 1 + if self.m_start > max_label + 1: + edisc[:, j + self.d_cont] = np.random.choice(list(range(max_label)), size=self.n) else: edisc[:, j + self.d_cont] = np.random.choice(list(range(self.m_start)), size=self.n) diff --git a/tests/tests_SEM/test_SEM_init_disc.py b/tests/tests_SEM/test_SEM_init_disc.py new file mode 100644 index 0000000..b1048d7 --- /dev/null +++ b/tests/tests_SEM/test_SEM_init_disc.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import numpy as np +from sklearn.preprocessing import LabelEncoder + +import glmdisc + + +def _model(n=300, levels=("a", "b", "c"), m_start=20): + model = glmdisc.Glmdisc(m_start=m_start) + model.predictors_cont = np.random.uniform(size=(n, 2)) + model.predictors_qual = np.random.choice(np.array(levels), size=(n, 2)).astype(object) + model.labels = np.random.binomial(1, .5, size=n) + return model + + +def test_init_disc_fits_each_label_encoder_once(monkeypatch): + np.random.seed(0) + model = _model() + fits = [] + original_fit = LabelEncoder.fit + + def counting_fit(self, y): + fits.append(len(y)) + return original_fit(self, y) + + monkeypatch.setattr(LabelEncoder, "fit", counting_fit) + complete = model._calculate_shape() + model._init_disc(complete) + assert len(fits) == model.d_qual From 50954dcdc3fdc45f31b4d90dabb0534a988d2dc9 Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:32:19 +0100 Subject: [PATCH 14/15] Draw the initial quantization with randint For a categorical feature with fewer levels than m_start the initial labels were drawn from range(max_label), which left out the last level and made np.random.choice fail on an empty list for a one-level feature. Draw from the right number of levels with np.random.randint, which also replaces the list-based choice for the continuous features. --- glmdisc/_fit.py | 12 ++++-------- tests/tests_SEM/test_SEM_init_disc.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/glmdisc/_fit.py b/glmdisc/_fit.py index f2754c7..5240086 100644 --- a/glmdisc/_fit.py +++ b/glmdisc/_fit.py @@ -95,7 +95,7 @@ def _init_disc(self, continu_complete_case): initial quantization and transformation of categorical features to integers """ self.affectations = [None] * (self.d_cont + self.d_qual) - edisc = np.random.choice(list(range(self.m_start)), size=(self.n, self.d_cont + self.d_qual)) + edisc = np.random.randint(0, self.m_start, size=(self.n, self.d_cont + self.d_qual)) for j in range(self.d_cont): edisc[np.invert(continu_complete_case[:, j]), j] = self.m_start @@ -104,13 +104,9 @@ def _init_disc(self, continu_complete_case): for j in range(self.d_qual): self.affectations[j + self.d_cont] = sk.preprocessing.LabelEncoder().fit(self.predictors_qual[:, j]) - # the last label of the (already fitted) encoder is the number of levels minus one - max_label = len(self.affectations[j + self.d_cont].classes_) - 1 - if self.m_start > max_label + 1: - edisc[:, j + self.d_cont] = np.random.choice(list(range(max_label)), size=self.n) - else: - edisc[:, j + self.d_cont] = np.random.choice(list(range(self.m_start)), - size=self.n) + # a feature cannot start with more groups than it has levels + m_init = min(self.m_start, len(self.affectations[j + self.d_cont].classes_)) + edisc[:, j + self.d_cont] = np.random.randint(0, m_init, size=self.n) predictors_trans[:, j] = (self.affectations[j + self.d_cont].transform( self.predictors_qual[:, j])).astype(int) diff --git a/tests/tests_SEM/test_SEM_init_disc.py b/tests/tests_SEM/test_SEM_init_disc.py index b1048d7..27a141a 100644 --- a/tests/tests_SEM/test_SEM_init_disc.py +++ b/tests/tests_SEM/test_SEM_init_disc.py @@ -28,3 +28,26 @@ def counting_fit(self, y): complete = model._calculate_shape() model._init_disc(complete) assert len(fits) == model.d_qual + + +def test_init_disc_draws_every_level(): + np.random.seed(0) + model = _model(levels=("a", "b", "c"), m_start=20) + edisc, predictors_trans = model._init_disc(model._calculate_shape()) + for j in range(model.d_qual): + assert set(np.unique(edisc[:, model.d_cont + j])) == {0, 1, 2} + + +def test_init_disc_caps_groups_at_m_start(): + np.random.seed(0) + model = _model(levels=tuple("abcdefgh"), m_start=3) + edisc, predictors_trans = model._init_disc(model._calculate_shape()) + for j in range(model.d_qual): + assert set(np.unique(edisc[:, model.d_cont + j])) == {0, 1, 2} + + +def test_init_disc_single_level(): + np.random.seed(0) + model = _model(levels=("a",), m_start=20) + edisc, predictors_trans = model._init_disc(model._calculate_shape()) + np.testing.assert_array_equal(edisc[:, 2:], 0) From d4cd16b6d1eee9dde2ee9deea69a898e6030989c Mon Sep 17 00:00:00 2001 From: adimajo Date: Thu, 29 Oct 2026 15:33:54 +0100 Subject: [PATCH 15/15] Reuse the stored encoders in the NN initializer _initialize_neural_net fitted a fresh LabelEncoder per categorical feature just to read its largest label, although fit already stored one in affectations. Read it from there. --- glmdisc/_fitNN.py | 4 +--- tests/tests_NN/test_NN_fit.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/glmdisc/_fitNN.py b/glmdisc/_fitNN.py index 8ed753f..e058359 100644 --- a/glmdisc/_fitNN.py +++ b/glmdisc/_fitNN.py @@ -12,7 +12,6 @@ import tensorflow as tf import tensorflow.keras.optimizers from loguru import logger -from scipy import stats from tensorflow.keras import Model from tensorflow.keras.callbacks import Callback, ReduceLROnPlateau from tensorflow.keras.layers import Dense @@ -152,8 +151,7 @@ def _initialize_neural_net(self): m_qual = [self.m_start] * (self.d_cont + self.d_qual) for j in range(self.d_qual): - num_levels = stats.describe(sk.preprocessing.LabelEncoder().fit(self.predictors_qual[:, j]).transform( - self.predictors_qual[:, j])).minmax[1] + num_levels = len(self.affectations[j + self.d_cont].classes_) - 1 if self.m_start > num_levels: m_qual[j] = num_levels diff --git a/tests/tests_NN/test_NN_fit.py b/tests/tests_NN/test_NN_fit.py index 1d8523a..113154a 100644 --- a/tests/tests_NN/test_NN_fit.py +++ b/tests/tests_NN/test_NN_fit.py @@ -5,6 +5,8 @@ import pandas as pd import random import glmdisc +import sklearn as sk +from glmdisc._fitNN import _initialize_neural_net import tensorflow from tensorflow.keras.optimizers import Adagrad from tensorflow.keras.callbacks import EarlyStopping @@ -209,3 +211,20 @@ def test_fit(): model.fit(predictors_cont=x, predictors_qual=None, labels=y, iter=20) model = glmdisc.Glmdisc(algorithm="NN", criterion="gini", test=False, validation=False) model.fit(predictors_cont=x, predictors_qual=None, labels=y, iter=20) + + +def test_initialize_neural_net_reuses_label_encoders(monkeypatch): + np.random.seed(0) + x, y, theta = glmdisc.Glmdisc.generate_data(100, 2) + xd = np.digitize(x, [0.333, 0.666]).astype(float) + model = glmdisc.Glmdisc(algorithm="NN", validation=False, test=False) + model.predictors_cont = x + model.predictors_qual = xd + model.labels = y + model._init_disc(model._calculate_shape()) + + def fail_fit(self, y): + raise AssertionError("LabelEncoder fitted again") + + monkeypatch.setattr(sk.preprocessing.LabelEncoder, "fit", fail_fit) + _initialize_neural_net(model)