scikit-learn's logistic regression module tops the activity risk list

Three of the five highest-risk functions in scikit-learn sit in sklearn/linear_model/_logistic.py, with the other two in sklearn/metrics/_classification.py, and all five were touched within the last week.

Stephen Collins ·
Generated by hotspots · free & open source
pip
$ pip install hotspots-cli
Activity Risk3.22Low
Hottest Function_logistic_regression_path

Run this on your own codebase

See if your own repo has a _logistic_regression_path-style hotspot — run this in any local git repo:

pip
$ pip install hotspots-cli
npm
$ npm install -g @stephencollinstech/hotspots
Run in any repo
$ hotspots analyze .
★ Star on GitHub

Key Points

What makes a cyclomatic complexity of 69 in `_logistic_regression_path` a problem in scikit-learn?

Cyclomatic complexity counts the independent execution paths through a function, and each path is a potential bug surface and a test case you need to write. A value of 69 is well past the 30 mark I treat as high, and it sits alongside a fan-out of 56, so the function branches a lot and calls a very wide set of other functions. That makes it hard to test in isolation and hard to predict the ripple effects of a change. It also sits in code last changed 6 days ago, with 39 touches in the last year.

How do I reduce cyclomatic complexity in python?

The usual technique is extract-method combined with decompose-conditional: pull each coherent branch into a named helper and leave the original function as a dispatcher. I would treat a value above 15 as a reason to consider splitting and above 30 as a reason to act soon. A concrete first step for `_logistic_regression_path` is to move argument normalization and deprecation handling into one helper, then give each solver branch its own function. That should shrink the main function's path count and give each piece a testable boundary.

Is scikit-learn actively maintained?

All five of my top hotspots are in the active tier. The three in _logistic.py have 39 touches in the last year and were last changed 6 days ago, while the two in _classification.py have 35 touches in the last year and were changed today. Across the repo, 2,171 of 4,213 functions are in active files. High structural complexity and active development coexist here, and the complexity is concentrated in code that is being worked on, which is where it deserves attention.

How do I reproduce this analysis?

Use the hotspots CLI from GitHub and check out the commit I analyzed, 6e37063. Then run `hotspots analyze . --mode snapshot --force` from the repository root. The same command works on any local git repo without configuration.

What does activity-weighted risk mean?

Activity-weighted risk combines structural complexity (cyclomatic complexity, nesting depth, and fan-out) with how much and how recently the file has changed, so functions that are both hard to understand and in changing code score highest. A function with a cyclomatic complexity of 80 that hasn't been touched in two years scores much lower than one with a complexity of 20 that is touched every week, because the dormant one has lower near-term regression risk. That ordering focuses refactoring effort where it reduces the chance of introducing bugs now, not just where the code looks complicated in the abstract.

Three of the top five hotspots in scikit-learn live in a single file, sklearn/linear_model/_logistic.py, so I treat the module as the unit of risk rather than any one function. The worst of them, _logistic_regression_path, has an activity-weighted risk score of 3.22 and 39 touches in the last year, the most recent 6 days ago, so I would not treat it as a backlog item. The analysis covers 4,213 functions, 442 of them in the critical band, and the remaining two top-five entries come from sklearn/metrics/_classification.py.

The table below ranks functions by activity-weighted risk — a score that multiplies structural complexity by recent commit frequency. A function that is both hard to understand (high cyclomatic complexity) and actively changing is a higher priority than one that is complex but untouched. CC = cyclomatic complexity (independent execution paths); ND = max nesting depth; FO = fan-out (distinct callees).

Top 5 Hotspots

FunctionFileRiskCCNDFO
_logistic_regression_pathsklearn/linear_model/_logistic.py3.269456
fitsklearn/linear_model/_logistic.py3.136233
_log_reg_scoring_pathsklearn/linear_model/_logistic.py3.015323
_check_targetssklearn/metrics/_classification.py2.916320
multilabel_confusion_matrixsklearn/metrics/_classification.py2.921324

Codemod / Tooling Files in Results

Two entries in the broader data come from third-party or non-Python code: isclose in sklearn/externals/array_api_extra/_agnostic/_elementwise.py is vendored external code, and Solve in sklearn/svm/src/liblinear/linear.cpp is a C++ source file from the bundled liblinear library. Neither appears in my top five, but both would show up in a critical-band listing. To exclude them, use { "exclude": ["sklearn/externals/", "sklearn/svm/src/liblinear/"] } in .hotspotsrc.json.

Activity Tier Distribution
Active2171Quiet2042

4,213 functions analyzed

The tier split is close to even: 2,171 functions are in active files and 2,042 are in quiet ones. All five of my top hotspots are active, and all five were changed within the last 6 days (the two in _classification.py today). That matters because a critical band combined with an active tier means the complexity is being exercised by ongoing edits, not sitting still.

The three _logistic.py functions share the same file-level history: 39 touches in the last year, 2 distinct authors in the last 90 days, a bug-fix rate of 67%, and no reverts. The 67% figure is a file-level count and does not mean any specific function here is defective. I read it as a reason to review changes to this file more carefully than the repo average. The absence of reverts and of bug-linked commits is worth stating just as plainly.

_logistic_regression_path — _logistic.py

_logistic_regression_path
sklearn/linear_model/_logistic.py
3.22
critical
CC 69
ND 4
FO 56
touches/yr 39
Cyclomatic Complexity 69
threshold: 10

I would start with _logistic_regression_path because it combines a cyclomatic complexity of 69 with a fan-out of 56 and was last changed 6 days ago. From the docstring, it computes a logistic regression model across a list of regularization strengths and reuses the previous solution to warm-start the next one. Its signature takes more than 20 parameters, including the solver, the penalty mix, sample weights, thread count, and a callback context. That parameter surface is a large part of why the path count is so high.

Nesting depth is only 4, so the complexity is breadth, not depth: many independent branches at a shallow level, probably one per solver, penalty, or option combination. A fan-out of 56 means the function touches a very wide set of collaborators, and in Python, where types resolve at runtime, the real coupling is likely broader than the number suggests. The docstring also marks Cs as deprecated since 1.10 with removal in 1.14, which means the function is carrying a migration shim alongside its normal logic.

My recommendation: separate the argument normalization and deprecation handling from the solver dispatch, then give each solver branch its own helper. Each helper can be tested against a fixed input, and the dispatcher shrinks to something reviewable.

fit — _logistic.py

fit
sklearn/linear_model/_logistic.py
3.12
critical
CC 36
ND 2
FO 33
touches/yr 39

The fit method has a cyclomatic complexity of 36, a nesting depth of only 2, and a fan-out of 33, with an activity-weighted risk score of 3.12. This is the public entry point for the estimator, so every caller of the logistic regression estimator inherits a change here. The excerpt shows a good deal of the branching is parameter reconciliation: a deprecated C checked against alpha, a deprecated intercept_scaling that emits a FutureWarning, and a special case that forces l1_ratio to 0 in certain conditions. After that come solver checking, array-namespace and device handling, and input validation.

The flat nesting is a point in its favor, since the code reads top to bottom. The cost is that two deprecation paths with different removal versions (1.14 for C, 1.12 for intercept_scaling) are interleaved with the main fitting logic. Those paths will disappear on a known schedule, and fit will get simpler when they do. Until then, I would pull the deprecation and parameter resolution into one private helper so the removals become a deletion in one place instead of surgery across a 36-path method.

_log_reg_scoring_path — _logistic.py

_log_reg_scoring_path
sklearn/linear_model/_logistic.py
2.98
critical
CC 15
ND 3
FO 23
touches/yr 39

_log_reg_scoring_path is the smallest of the three by path count, with a cyclomatic complexity of 15, a nesting depth of 3, and a fan-out of 23, but it still lands in the critical band with a risk score of 2.98. By name and docstring, it scores models across the regularization path, which is the cross-validation side of the same machinery that _logistic_regression_path implements. It takes train and test indices, a scoring argument, and a score_params argument, and it carries the same deprecated Cs parameter.

The practical concern is coupling between the two path functions. They share a parameter vocabulary and a deprecation timeline, so a change to one is likely to require a matching change to the other. I would review them together, and I would not refactor one without a plan for the other. Its moderate size makes it a reasonable place to try extracting shared argument handling first.

_check_targets — _classification.py

_check_targets
sklearn/metrics/_classification.py
2.94
critical
CC 16
ND 3
FO 20
touches/yr 35

Outside the logistic module, _check_targets in sklearn/metrics/_classification.py has a risk score of 2.94, a cyclomatic complexity of 16, and a fan-out of 20. It was changed today, with 35 touches in the last year and 3 distinct authors in the last 90 days. The docstring says it verifies that y_true and y_pred belong to the same classification task and raises a ValueError for mismatched lengths, mixed multilabel and multiclass targets, continuous or multioutput targets, and mixed string and integer labels.

That is a long list of rejection conditions, which accounts for the path count. The excerpt also shows it moving inputs to a chosen array namespace and device before validating, so there is an array-API layer sitting on top of the validation rules. A validator like this is called by many metric functions, so its blast radius extends to everything that builds on it. The file-level bug-fix rate is 29%, lower than the logistic file, with no reverts.

My recommendation: split the namespace and device preparation from the target-type checks. The type-compatibility rules can then be tested as a pure table of input combinations, independent of array backends.

multilabel_confusion_matrix — _classification.py

multilabel_confusion_matrix
sklearn/metrics/_classification.py
2.93
critical
CC 21
ND 3
FO 24
touches/yr 35

multilabel_confusion_matrix is a public metric with a cyclomatic complexity of 21, a nesting depth of 3, and a fan-out of 24, scoring 2.93. Its docstring describes two modes, class-wise by default and sample-wise when samplewise=True, and says multiclass input is treated as binarized one-vs-rest. Supporting both modes, plus multilabel indicators, multiclass data, sparse matrices, optional label selection, and sample weights, is a plausible source of its branching.

Because it is public API, a regression here reaches users directly in their evaluation numbers. It shares its file’s history with _check_targets: 35 touches in the last year, a bug-fix rate of 29%, and no reverts. I would extract the class-wise and sample-wise paths into separate helpers behind the existing signature so the public interface does not change.

Where to start

I would take the three _logistic.py functions as one review unit and sequence the work: first the deprecation and parameter handling in fit and _logistic_regression_path, since those paths have known removal versions and will shrink on their own schedule, then the solver dispatch inside _logistic_regression_path, which is where the cyclomatic complexity of 69 sits. The two _classification.py functions are a smaller, separate job: split array-backend preparation from the label-type rules in _check_targets, then separate the two modes in multilabel_confusion_matrix.

For context, the quiet critical functions in the broader data score far lower than these five, and I would not spend the next review cycle on them.

Reproduce This Analysis

git clone https://github.com/scikit-learn/scikit-learn
cd scikit-learn
git checkout 6e37063f7ab7ef7a7e257b9d44bc3ec6ee1b1bcd
hotspots analyze . --mode snapshot --force

To run the same analysis on your own codebase, run hotspots analyze . --mode snapshot in any local git repo — no configuration required.

I use Hotspots to highlight structural and activity risk — not “bad code.” I treat these findings as a prioritization aid, not a bug predictor. Editorial policy →

Was this useful? Tell me more →

Related Analyses