ENH: Add MedianAbsoluteDeviationScaler (MADScaler) to sklearn.preprocessing

### Describe the workflow you want to enable

Today, if a user wants to centre features by the median and scale them by the median absolute deviation (MAD) they must hand-roll code like:
mad = 1.4826 * np.median(np.abs(X - np.median(X, axis=0)), axis=0)
X_scaled = (X - np.median(X, axis=0)) / mad

A built-in MedianAbsoluteDeviationScaler (or a statistic="mad" option on RobustScaler) would let them write a single, self-documenting line:
from sklearn.preprocessing import MedianAbsoluteDeviationScaler
X_scaled = MedianAbsoluteDeviationScaler().fit_transform(X)

That makes robust MAD scaling first-class, composable in pipelines, and reversible via inverse_transform().

### Describe your proposed solution

Add a new transformer:
class MedianAbsoluteDeviationScaler(BaseEstimator, TransformerMixin):
    with_centering: bool = True
    with_scaling:   bool = True
    copy:           bool = True
    unit_variance:  bool = False

    # learned in fit
    center_: ndarray
    scale_: ndarray

Fit logic
1. center_ = np.median(X, axis=0) (if with_centering)

2. mad = np.median(np.abs(X - center_), axis=0) * 1.4826

3. Guard against zeros with float_eps, store in scale_.

transform() and inverse_transform() reuse the pattern from RobustScaler.

Docs / tests

- Unit tests for shape preservation, inverse-transform round-trip, and robustness to outliers.

- A short subsection in preprocessing.rst and a gallery example comparing Standard, Robust (IQR) and MAD scalers.

- Changelog bullet in whats_new/v1.5.rst.

I am happy to implement this within ~2 weeks.

### Describe alternatives you've considered, if relevant

- Keep user-land recipes – fragments the ecosystem and lacks inverse_transform().

- Extend RobustScaler with statistic={"iqr","mad"} (default "iqr"). This also works, but changes a long-standing API and may require a deprecation cycle.

### Additional context

- MAD is a well-known σ-consistent robust scale estimator, more efficient than IQR for symmetric heavy-tailed or Laplace-like data.

- Estimated code diff ≈ 20 LOC plus tests/docs – labelled “good first issue” size.

/cc @jnothman @glemaitre for initial design feedback — thanks!

COMMENT:
Thanks for the quick feedback!
Totally agree that a *separate transformer* is cleaner than over-loading `RobustScaler`.

---

### ✔️ Usefulness check – side-by-side plot

I copied the “Compare the effect of different scalers on data with outliers” example and added a `MedianAbsoluteDeviationScaler` implementation that’s ~15 LOC (inline for now).  
Here is the extra block:

```python
from sklearn.base import BaseEstimator, TransformerMixin
import numpy as np

class MedianAbsoluteDeviationScaler(BaseEstimator, TransformerMixin):
    def __init__(self, *, with_centering=True, with_scaling=True,
                 copy=True, unit_variance=False):
        self.with_centering = with_centering
        self.with_scaling = with_scaling
        self.copy = copy
        self.unit_variance = unit_variance

    def fit(self, X, y=None):
        X = np.asarray(X)
        if self.with_centering:
            self.center_ = np.median(X, axis=0)
        else:
            self.center_ = np.zeros(X.shape[1], dtype=X.dtype)

        if self.with_scaling:
            mad = np.median(np.abs(X - self.center_), axis=0)
            if self.unit_variance:
                mad *= 1.4826  # σ-consistent
            self.scale_ = np.where(mad == 0, 1.0, mad)
        else:
            self.scale_ = np.ones(X.shape[1], dtype=X.dtype)
        return self

    def transform(self, X):
        X = np.asarray(X, dtype=float, copy=self.copy)
        X -= self.center_
        X /= self.scale_
        return X

    def inverse_transform(self, X):
        X = np.asarray(X, dtype=float, copy=self.copy)
        X *= self.scale_
        X += self.center_
        return X
```
and I appended:
("Data after MAD scaling", MedianAbsoluteDeviationScaler().fit_transform(X)),
to the distributions list.

Early result: on the California-Housing two-feature slice the MAD-scaled cloud looks very similar to the IQR‐based RobustScaler but with a slightly tighter centre for the heavy-tailed “MedInc” axis. I’ll post the full figure once I clean it up, so we can decide if the visual difference warrants merging.

About the 1.4826 “multiplier constant”
RobustScaler doesn’t try to make the scaled data unit-variance – it just divides by the raw IQR.

If we copy that behaviour, we can skip the 1.4826 factor and stay conceptually parallel: centre by the median, scale by raw MAD.

If users want unit variance, we can mimic the unit_variance=True flag that RobustScaler already has (that one divides by IQR/1.349). In the code above, the factor is applied only when unit_variance=True.

Happy to drop the constant entirely (and maybe keep the optional unit_variance flag for parity).

Let me know if:

- you'd like me to push the notebook/PNG here, or

- the API should be tweaked before I start a formal PR.

Thanks again!
/cc @jnothman @glemaitre

Labels: New Feature, module:preprocessing, Needs Investigation