File size: 5,225 Bytes
cf8ab7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
"""Write src/sklearn_params.json: every setting of each model in models.json,
read from scikit-learn's parameter constraints and docstrings.

Run it with the scikit-learn version Pyodide ships (see the PYODIDE_SKLEARN
note in the README), from the backend folder:

    uv run --isolated --no-project --python 3.12 --with "scikit-learn==1.4.2" \\
        --with "numpy<2" python scripts/generate_sklearn_params.py
"""

import json
import re
import sys
from numbers import Integral
from pathlib import Path

import sklearn
from sklearn.utils._param_validation import Hidden, Interval, StrOptions

SRC = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(SRC))
from logic import ESTIMATORS, MODEL_SPECS  # noqa: E402

# Settings that don't change what is learned, or are gone in newer scikit-learn
EXCLUDED = {"n_jobs", "verbose", "warm_start", "multi_class"}
# "precomputed" needs a matrix students can't give from the page
EXCLUDED_OPTIONS = {"precomputed"}


def describe(constraints: list) -> dict | None:
    """The control for a setting, or None if students can't set it from the page
    (e.g. it only takes callables, dicts or arrays)."""
    strings, none, boolean, intervals = [], False, False, []
    for c in constraints:
        if c is None:
            none = True
        elif c == "boolean":
            boolean = True
        elif c == "random_state":
            # A seed; scikit-learn also takes a RandomState object
            intervals.append(Interval(Integral, 0, 2**32 - 1, closed="both"))
            none = True
        elif isinstance(c, StrOptions):
            strings += sorted(c.options - c.deprecated - EXCLUDED_OPTIONS)
        elif isinstance(c, Interval):
            intervals.append(c)
        elif isinstance(c, Hidden):
            continue
        # Anything else (callable, dict, array-like, ...) has no control

    if boolean and not (strings or none or intervals):
        return {"kind": "boolean"}

    number = None
    if intervals:
        integer = all(i.type is Integral for i in intervals)
        number = {"integer": integer}
        lows = [i for i in intervals if i.left is not None]
        highs = [i for i in intervals if i.right is not None]
        # Unbounded if any interval is; open if the bounding interval is
        if len(lows) == len(intervals):
            low = min(lows, key=lambda i: i.left)
            number["min"] = low.left
            if low.closed in ("right", "neither"):
                number["minExclusive"] = True
        if len(highs) == len(intervals):
            high = max(highs, key=lambda i: i.right)
            number["max"] = high.right
            if high.closed in ("left", "neither"):
                number["maxExclusive"] = True

    if number and not strings:
        spec = {"kind": "integer" if number.pop("integer") else "number", **number}
        if none:
            spec["nullable"] = "None"
        return spec
    if strings and not number and not none:
        return {"kind": "choice", "options": [{"value": s, "label": s} for s in strings]}
    options = [{"value": s, "label": s} for s in strings] + ([{"value": None, "label": "None"}] if none else [])
    if len(options) + (number is not None) < 2:
        return None
    spec = {"kind": "mixed", "options": options}
    if number:
        spec["number"] = number
    return spec


def descriptions(estimator) -> dict[str, str]:
    """First sentence of each setting's description in the docstring."""
    doc = estimator.__doc__ or ""
    section = doc.split("Parameters\n    ----------\n", 1)[-1]
    section = re.split(r"\n    \w[\w ]*\n    -{3,}\n", section, maxsplit=1)[0]
    result = {}
    for match in re.finditer(r"^    (\w+) : .*\n((?:(?: {8}.*)?\n)+)", section, re.M):
        paragraph = " ".join(match.group(2).strip().split("\n\n")[0].split())
        sentence = re.split(r"(?<=\.)\s(?=[A-Z])", paragraph, maxsplit=1)[0]
        result[match.group(1)] = clean_rst(sentence)
    return result


def clean_rst(text: str) -> str:
    text = re.sub(r":\w+:`~?([^`<]*?)\s*(?:<[^>]*>)?`", lambda m: m.group(1).split(".")[-1], text)
    return text.replace("``", "").replace("`", "")


def generate() -> dict:
    models = {}
    for spec in MODEL_SPECS.values():
        estimator = ESTIMATORS[spec["estimator"]]
        defaults = estimator().get_params()
        texts = descriptions(estimator)
        params = []
        for name, constraints in estimator._parameter_constraints.items():
            if name in EXCLUDED:
                continue
            control = describe(constraints)
            if control is None:
                continue
            params.append({
                "name": name,
                "label": name if len(name) <= 2 else name.replace("_", " ").capitalize(),
                "description": texts.get(name, ""),
                **control,
                "default": defaults[name],
            })
        models[spec["id"]] = params
    return {"sklearn_version": sklearn.__version__, "models": models}


if __name__ == "__main__":
    out = SRC / "sklearn_params.json"
    out.write_text(json.dumps(generate(), indent=2) + "\n")
    print(f"Wrote {out} (scikit-learn {sklearn.__version__})")