[Solved] Picard library modifies sklearn FastICA causing it to reject a default value for the `fun` parameter

### Describe the bug

I wrote some code that did not specify a `fun` parameter to FastICA and somehow this default value was rejected

```
  File "/home/louis/dev/arxiv_explorer/benchmark_ica_impls.py", line 84, in <module>                                                                                                                                                                          
    S_sklearn = ica_sklearn.fit_transform(X_whitened2)                                                                                                                                                                                                        
  File "/home/louis/dev/arxiv_explorer/.venv/lib/python3.13/site-packages/sklearn/utils/_set_output.py", line 316, in wrapped                                                                                                                                 
    data_to_wrap = f(self, X, *args, **kwargs)                                                                                                                                                                                                                
  File "/home/louis/dev/arxiv_explorer/.venv/lib/python3.13/site-packages/sklearn/base.py", line 1329, in wrapper                                                                                                                                             
    estimator._validate_params()
    ~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/home/louis/dev/arxiv_explorer/.venv/lib/python3.13/site-packages/sklearn/base.py", line 492, in _validate_params
    validate_parameter_constraints(      
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^                         
        self._parameter_constraints,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                           
        self.get_params(deep=False),                        
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        caller_name=self.__class__.__name__,                
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )                      
    ^                      
  File "/home/louis/dev/arxiv_explorer/.venv/lib/python3.13/site-packages/sklearn/utils/_param_validation.py", line 98, in validate_parameter_constraints
    raise InvalidParameterError(
    ...<2 lines>...                                         
    )                          
sklearn.utils._param_validation.InvalidParameterError: The 'fun' parameter of FastICA must be a str among {'exp', 'cube', 'tanh'} or a callable. Got 'logcosh' instead.
```

I don't want to dwell on it but I find it very confusing because it is a default value and the value that is documented as of 1.8.0, and changing it to the implied `fun` value of "tanh" causes an unset variable error because the `g` variable is not set for a value "tanh"! 

### Steps/Code to Reproduce

```py
from sklearn.decomposition import FastICA

t0 = time.perf_counter()
pca2 = PCA(n_components=N_PCA_COMPONENTS, whiten=True, svd_solver='randomized')
X_whitened2 = pca2.fit_transform(X)
t_pca2 = time.perf_counter() - t0

t0 = time.perf_counter()
ica_sklearn = FastICA(n_components=N_TOPICS, max_iter=200, random_state=42)
S_sklearn = ica_sklearn.fit_transform(X_whitened2)
t_ica_sklearn = time.perf_counter() - t0

print(f"PCA whitening: {t_pca2:.3f}s")
print(f"sklearn FastICA: {t_ica_sklearn:.3f}s")
print(f"Total: {t_pca2 + t_ica_sklearn:.3f}s")
```

### Expected Results

I was comparing the value against picard and my own implementation of FastICA, it works with `fun="exp"` just fine.

### Actual Results

Above

### Versions

```shell
(arxiv_explorer) louis 🌟 ~/dev/arxiv_explorer $ python -c 'import sklearn; sklearn.show_versions()'

System:
    python: 3.13.3 (main, Apr  9 2025, 04:03:52) [Clang 20.1.0 ]
executable: /home/louis/dev/arxiv_explorer/.venv/bin/python3
   machine: Linux-6.8.0-57-generic-x86_64-with-glibc2.39

Python dependencies:
      sklearn: 1.8.0
          pip: None
   setuptools: None
        numpy: 2.3.5
        scipy: 1.16.3
       Cython: None
       pandas: 2.3.3
   matplotlib: None
       joblib: 1.5.3
threadpoolctl: 3.6.0

Built with OpenMP: True

threadpoolctl info:
       user_api: blas
   internal_api: openblas
    num_threads: 20
         prefix: libscipy_openblas
       filepath: /home/louis/dev/arxiv_explorer/.venv/lib/python3.13/site-packages/numpy.libs/libscipy_openblas64_-fdde5778.so
        version: 0.3.30
threading_layer: pthreads
   architecture: Haswell

       user_api: blas
   internal_api: openblas
    num_threads: 20
         prefix: libscipy_openblas
       filepath: /home/louis/dev/arxiv_explorer/.venv/lib/python3.13/site-packages/scipy.libs/libscipy_openblas-b75cc656.so
        version: 0.3.29.dev
threading_layer: pthreads
   architecture: Haswell

       user_api: openmp
   internal_api: openmp
    num_threads: 20
         prefix: libgomp
       filepath: /home/louis/dev/arxiv_explorer/.venv/lib/python3.13/site-packages/scikit_learn.libs/libgomp-e985bcbb.so.1.0.0
        version: None
```

COMMENT:
Yes it would have been too much of an interruption to go from the initial report (which I wanted to make) to a minimal reproduction. I thought the initial report was still worth making as it came from this library.

Full code I used was this version: I checked and it does reproduce still

<details><summary>Details</summary>
<p>

```py
"""Benchmark: polars-fastembed S³ vs manual Picard implementation (fair comparison)."""

import time
import polars as pl
import polars_fastembed
import numpy as np
from sklearn.decomposition import PCA
from picard import Picard

# =============================================================================
# Setup
# =============================================================================
N_DOCS = 1000
N_TOPICS = 10
N_PCA_COMPONENTS = 100

print(f"Loading {N_DOCS} documents...")
df = pl.read_parquet("output/arxiv_embeddings.parquet").head(N_DOCS)

# =============================================================================
# Benchmark 1: polars-fastembed S³ (document-topic assignment only)
# =============================================================================
print("\n" + "=" * 60)
print("POLARS-FASTEMBED S³ (document-topic only)")
print("=" * 60)

t0 = time.perf_counter()
result_pf = df.fastembed.s3_topics(
    embedding_column="embedding",
    n_components=N_TOPICS,
)
t_pf = time.perf_counter() - t0

print(f"Time: {t_pf:.3f}s")

# =============================================================================
# Benchmark 2: Manual PCA + Picard
# =============================================================================
print("\n" + "=" * 60)
print("MANUAL PCA + PICARD")
print("=" * 60)

X = np.stack(df["embedding"].to_numpy())

# PCA whitening
t0 = time.perf_counter()
pca = PCA(n_components=N_PCA_COMPONENTS, whiten=True, svd_solver='randomized')
X_whitened = pca.fit_transform(X)
t_pca = time.perf_counter() - t0

print(f"PCA whitening ({N_PCA_COMPONENTS}D): {t_pca:.3f}s")
print(f"Explained variance: {pca.explained_variance_ratio_.sum():.1%}")

# Picard ICA
t0 = time.perf_counter()
ica = Picard(n_components=N_TOPICS, ortho=True, extended=True, max_iter=200, random_state=42)
S = ica.fit_transform(X_whitened)
t_ica = time.perf_counter() - t0

print(f"Picard ICA: {t_ica:.3f}s")
print(f"Total: {t_pca + t_ica:.3f}s")

# =============================================================================
# Benchmark 3: Manual sklearn FastICA (same as linfa uses)
# =============================================================================
print("\n" + "=" * 60)
print("MANUAL PCA + SKLEARN FASTICA")
print("=" * 60)

from sklearn.decomposition import FastICA

t0 = time.perf_counter()
pca2 = PCA(n_components=N_PCA_COMPONENTS, whiten=True, svd_solver='randomized')
X_whitened2 = pca2.fit_transform(X)
t_pca2 = time.perf_counter() - t0

t0 = time.perf_counter()
ica_sklearn = FastICA(n_components=N_TOPICS, max_iter=200, random_state=42)
S_sklearn = ica_sklearn.fit_transform(X_whitened2)
t_ica_sklearn = time.perf_counter() - t0

print(f"PCA whitening: {t_pca2:.3f}s")
print(f"sklearn FastICA: {t_ica_sklearn:.3f}s")
print(f"Total: {t_pca2 + t_ica_sklearn:.3f}s")

# =============================================================================
# Summary
# =============================================================================
print("\n" + "=" * 60)
print("TIMING SUMMARY (document-topic assignment only)")
print("=" * 60)
print(f"polars-fastembed (linfa FastICA, no PCA reduction): {t_pf:.3f}s")
print(f"sklearn FastICA + PCA reduction:                    {t_pca2 + t_ica_sklearn:.3f}s")
print(f"Picard + PCA reduction:                             {t_pca + t_ica:.3f}s")
```

</p>
</details> 

For reproducibility I think the packages are likely relevant, the env had these deps:

<details><summary>Details</summary>
<p>

```
Package               Version     Editable project location
--------------------- ----------- ------------------------------
aiohappyeyeballs      2.6.1
aiohttp               3.13.2
aiosignal             1.4.0
annotated-doc         0.0.4
annotated-types       0.7.0
anyio                 4.12.0
arxiv-explorer        0.1.0       /home/louis/dev/arxiv_explorer
attrs                 25.4.0
certifi               2025.11.12
charset-normalizer    3.4.4
click                 8.3.1
datasets              4.4.2
dill                  0.4.0
fastapi               0.125.0
filelock              3.20.1
frozenlist            1.8.0
fsspec                2025.10.0
h11                   0.16.0
hf-xet                1.2.0
httpcore              1.0.9
httptools             0.7.1
httpx                 0.28.1
huggingface-hub       1.2.3
idna                  3.11
joblib                1.5.3
llvmlite              0.46.0
multidict             6.7.0
multiprocess          0.70.18
numba                 0.63.1
numpy                 2.3.5
packaging             25.0
pandas                2.3.3
polars                1.36.1
polars-distance       0.5.3
polars-fastembed      0.1.12
polars-fastembed-cuda 0.1.10
polars-runtime-32     1.36.1
propcache             0.4.1
pyarrow               22.0.0
pydantic              2.12.5
pydantic-core         2.41.5
pynndescent           0.5.13
python-dateutil       2.9.0.post0
python-dotenv         1.2.1
python-picard         0.8.1
pytz                  2025.2
pyyaml                6.0.3
requests              2.32.5
scikit-learn          1.8.0
scipy                 1.16.3
shellingham           1.5.4
six                   1.17.0
starlette             0.50.0
threadpoolctl         3.6.0
tqdm                  4.67.1
ty                    0.0.3
typer-slim            0.20.0
typing-extensions     4.15.0
typing-inspection     0.4.2
tzdata                2025.3
umap-learn            0.5.9.post2
urllib3               2.6.2
uvicorn               0.38.0
uvloop                0.22.1
watchfiles            1.1.1
websockets            15.0.1
xxhash                3.6.0
yarl                  1.22.0
```

</p>
</details> 

scikit-learn was 1.8.0 and it was this library which raised the error.

I reduced it to this:

```py
import numpy as np
from picard import Picard
from sklearn.decomposition import FastICA

X = np.random.randn(1000, 384)

ica = Picard(n_components=10, random_state=42)
ica.fit_transform(X)

ica_sklearn = FastICA(n_components=10, random_state=42)
ica_sklearn.fit_transform(X)
```

I can see why, it's not scikit-learn it's the picard library that overrides it! 🤦‍♂️ 

https://github.com/mind-inria/picard/blob/bbeba2a49f1fc406eadd25d3cffc51c5bcf47f1a/picard/dropin_sklearn.py#L108-L112

```py
        # update parameters constraint dict
        self._parameter_constraints["fun"] = [
            StrOptions({"tanh", "exp", "cube"}),
            callable,
        ]
```

Labels: Bug, Needs Reproducible Code