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
| Function | File | Risk | CC | ND | FO |
|---|---|---|---|---|---|
_logistic_regression_path | sklearn/linear_model/_logistic.py | 3.2 | 69 | 4 | 56 |
fit | sklearn/linear_model/_logistic.py | 3.1 | 36 | 2 | 33 |
_log_reg_scoring_path | sklearn/linear_model/_logistic.py | 3.0 | 15 | 3 | 23 |
_check_targets | sklearn/metrics/_classification.py | 2.9 | 16 | 3 | 20 |
multilabel_confusion_matrix | sklearn/metrics/_classification.py | 2.9 | 21 | 3 | 24 |
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.
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
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
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 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
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 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 →