skip to content

Why must a scikit-learn estimator's __init__ store every argument unchanged?

level: middleimportance: must knowfreq 62%

answer

  1. the constructor is not a place to compute
  2. introspection over the signature
  3. values must survive a round-trip
  4. clone rebuilds by calling __init__
  5. get_params does getattr by parameter name

basics

~20 s

In scikit-learn, get_params reads the init signature and returns the same-named attributes, and clone() feeds those values straight back into the constructor. Any renaming, conversion or validation inside init breaks that round-trip and makes clone raise RuntimeError.

solid answer

~40 s

scikit-learn's `BaseEstimator.get_params` does not track what you assigned — it inspects the `__init__` signature, collects the parameter names, and does a `getattr` for each one. `clone()` then rebuilds the estimator by calling `Klass(**params)` and verifies that the new object reports back the *identical* objects it was handed; if not, it raises `RuntimeError` saying the constructor either does not set or modifies the parameter. So `self.alpha = alpha` is required, while `self.alpha = float(alpha)`, `self.alpha = alpha or 1.0`, or storing it under another name all break cloning. Constructors must also be plain keyword parameters: `*args` makes `get_params` raise, and anything swallowed by `**kwargs` is invisible to it and silently lost on clone. All conversion, validation and derived state belongs in `fit`, written to trailing-underscore attributes.

code

python · 15 lines
python
from sklearn.base import BaseEstimator, clone

class Bad(BaseEstimator):
    def __init__(self, alpha=1.0):
        self.alpha = float(alpha) * 2      # modified -> round-trip fails

class Good(BaseEstimator):
    def __init__(self, alpha=1.0):
        self.alpha = alpha                 # stored verbatim

clone(Good(alpha=1.0))                     # fine
try:
    clone(Bad(alpha=1.0))
except RuntimeError as exc:
    print(exc)                             # constructor modifies parameter alpha

go deeper

for a junior

Be able to write the constructor correctly: one explicit keyword argument per hyperparameter, each assigned to a same-named attribute and nothing else. Say plainly that computation belongs in fit.

for a middle

Explain the mechanism — get_params inspects the init signature and does getattr, clone passes those values back to the constructor and checks they come out unchanged, so any conversion raises RuntimeError.

for a senior

Show you have debugged this: an estimator that works standalone but misbehaves under cross-validation or a hyperparameter search, traced to a non-round-tripping constructor or parameters swallowed by **kwargs.

for a principal

Frame it as a design choice — the library buys composability by constraining the constructor to be inert, and the price is that estimator authors must move validation into fit. Be ready to weigh that against a validating-constructor design.

## The rule A scikit-learn estimator's `__init__` does one thing: it assigns each argument to an attribute of exactly the same name, verbatim. No type coercion, no validation, no defaults filled in, no derived values, no reading files, no touching data. `def __init__(self, alpha=1.0): self.alpha = alpha`. That is the whole of it. This looks like a stylistic convention. It is not — it is what makes the rest of the library work. ## How get_params actually works `BaseEstimator.get_params` is generic code that has never seen your class. It cannot know which attributes are hyperparameters and which are scratch state. So it uses the only reliable declaration available: the signature of `__init__`. It calls `inspect.signature` on the constructor, takes every parameter except `self`, and then does `getattr(self, name)` for each. Two consequences follow immediately. First, the *name* matters: if the argument is `alpha` but you store `self.a = alpha`, `get_params` raises an `AttributeError`, because it looks for `self.alpha`. Second, the *value* matters: `get_params` reports whatever the attribute currently holds, not what the caller passed. `get_params(deep=True)` is the default and additionally walks any parameter value that is itself an estimator, exposing its parameters under `name__subname`. `deep=False` gives you only this estimator's own parameters. ## What clone does with those values `sklearn.base.clone(estimator)` produces a fresh, unfitted estimator with the same configuration. Its implementation is roughly: take `get_params(deep=False)`, clone each value, construct `Klass(**those_params)`, then call `get_params(deep=False)` on the *new* object and check that each value is the same object it just passed in. The check is an identity check, not equality. Now put a conversion in the constructor. `self.alpha = float(alpha) * 2` with `alpha=1.0` stores `2.0`. `get_params` reports `alpha=2.0`. `clone` constructs `Klass(alpha=2.0)`, which stores `4.0`. `2.0 is not 4.0`, so clone raises `RuntimeError` complaining that the constructor either does not set or modifies the parameter. Even a benign-looking `self.tags = list(tags)` fails: the copy is equal but not identical. ## Why this matters everywhere Every place scikit-learn needs "the same model, freshly configured" goes through clone: each fold of a cross-validation, each candidate of a hyperparameter search, each step when a pipeline is fitted, each base estimator inside an ensemble or a one-vs-rest wrapper. An estimator that cannot be cloned works perfectly when you call `fit` and `predict` by hand, and then blows up — or, worse, silently uses wrong hyperparameters — the moment you hand it to any of that machinery. That gap between "works in my notebook" and "fails under cross-validation" is exactly what interviewers are probing. ## The varargs rules, which differ The two forms of variadic argument fail differently, and knowing which is which is a good signal. - `def __init__(self, *args)`: `get_params` raises `RuntimeError` outright, telling you that scikit-learn estimators must always specify their parameters in the signature of `__init__`. - `def __init__(self, **kwargs)`: no error. The `**kwargs` parameter is simply skipped, so any hyperparameter passed through it is invisible to `get_params`. It is not reported, not searched over, and not restored by clone — the clone silently comes back with constructor defaults. Silent is worse than loud. ## Where the work goes instead Everything you were tempted to put in `__init__` goes in `fit`: - **Validation.** Raise on a bad `alpha` inside `fit`, not in the constructor. This is why `LogisticRegression(C=-1)` constructs happily and only complains when you fit it. Built-in estimators drive this from a private `_parameter_constraints` mechanism invoked during fit; a custom estimator can just check its arguments at the top of `fit`. - **Coercion.** `check_array` / `validate_data` on `X` inside `fit`; converting a list hyperparameter to an array inside `fit` and storing the result as `self.alpha_`. - **Derived state.** Anything computed from the hyperparameters or the data is fitted state and takes a trailing underscore, keeping it clearly separate from the untouched constructor parameters. `set_params(**params)` is the mirror image: it validates names against `get_params` and does plain `setattr`, which is why it can also reach nested estimators with the `component__param` syntax. Because it only sets attributes, a constructor that computed things would be bypassed entirely by `set_params` — another reason the constructor must stay inert. ## Checklist for a custom estimator's constructor Every argument is an explicit keyword parameter with a default; every assignment is `self.name = name`; no `*args`; nothing else in the body. If you can delete the body and regenerate it mechanically from the signature, it is correct.

  • What is the difference in behaviour between *args and **kwargs in an estimator's constructor?
    `*args` is a hard failure: `get_params` raises `RuntimeError` telling you estimators must declare their parameters in the `__init__` signature. `**kwargs` is a silent failure: those parameters are skipped by the introspection, so they never appear in `get_params`, never get searched over, and are quietly replaced by constructor defaults whenever the estimator is cloned. Prefer the loud failure — always declare explicit keyword parameters.
  • If validation cannot live in __init__, where do scikit-learn estimators check their hyperparameters?
    In `fit`. That is why `LogisticRegression(C=-1)` constructs without complaint and only raises when you fit it. Built-in estimators declare a private `_parameter_constraints` mapping that is checked at the start of fitting; a custom estimator simply validates its arguments in the first lines of `fit` and raises `ValueError`. Anything derived from a hyperparameter is stored as a trailing-underscore attribute, not written back over the parameter.
  • A custom estimator seems to ignore the hyperparameters you set when it runs inside cross-validation. What do you check first?
    That `get_params` round-trips. Confirm each constructor argument is stored unchanged under its own name and that nothing is swallowed by `**kwargs`, then call `clone(est)` directly and inspect the result's `get_params()`. Cross-validation never fits the object you passed — it fits clones — so any parameter that does not survive `get_params` → constructor → `get_params` is effectively invisible to it.

saying these in an interview costs you the question

  • Says __init__ should validate arguments and raise on bad ones
  • Converts a list or string argument to another type inside __init__
  • Thinks get_params returns every instance attribute it finds
  • Uses **kwargs in the constructor to stay flexible
  • Believes clone copies the fitted model rather than rebuilding it

context