Why must a scikit-learn estimator's __init__ store every argument unchanged?
answer
- the constructor is not a place to compute
- introspection over the signature
- values must survive a round-trip
- clone rebuilds by calling __init__
- get_params does getattr by parameter name
basics
~20 sIn 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 sscikit-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 linesfrom 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 alphago deeper
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.
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.
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.
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