CoolFace
Modelpublic

OneScience-Group/RF-ClimParam

sourceHugging Faceapache-2.0updated 17d agoView on Hugging Face
0likes31downloads
rf_climparam.py233 linesDownload Raw Back to model
1"""Pure NumPy joint multi-output random forests for the SAM parameterization."""2 3from dataclasses import dataclass4 5import numpy as np6 7 8FORMAT_VERSION = "rf_climparam_v2"9MODEL_NAME = "RF-ClimParam"10SCALES = ("x4", "x8", "x16", "x32")11TEND_INPUT_NAMES = tuple([f"T_{i:02d}" for i in range(48)] +12                         [f"qT_{i:02d}" for i in range(48)] +13                         [f"qp_{i:02d}" for i in range(48)] + ["abs_y"])14TEND_OUTPUT_NAMES = tuple([f"hL_tend_{i:02d}" for i in range(48)] +15                          [f"qT_tend_{i:02d}" for i in range(48)] +16                          [f"qp_tend_{i:02d}" for i in range(48)])17DIFF_INPUT_NAMES = tuple([f"T_low_{i:02d}" for i in range(15)] +18                         [f"qT_low_{i:02d}" for i in range(15)] +19                         [f"u_low_{i:02d}" for i in range(15)] +20                         [f"v_nh_low_{i:02d}" for i in range(15)] +21                         ["windsurf", "abs_y"])22DIFF_OUTPUT_NAMES = tuple([f"Dbar_{i:02d}" for i in range(15)] +23                          ["hL_surface_flux", "qT_surface_flux"])24 25 26def _check_array(name, value, features):27    value = np.asarray(value)28    if value.ndim != 2 or value.shape[1] != features:29        raise ValueError(f"{name} must have shape [N,{features}], got {value.shape}")30    if value.dtype not in (np.float32, np.float64) or not np.isfinite(value).all():31        raise ValueError(f"{name} must be finite float32/float64")32    return value.astype(np.float32, copy=False)33 34 35@dataclass36class TreeConfig:37    max_depth: int = 538    min_samples_leaf: int = 339    max_features: object = "sqrt"40    split_candidates: int = 841 42 43class ExtraRandomRegressionTree:44    """Randomized recursive tree whose leaves hold one joint output vector."""45 46    def __init__(self, config, seed=0):47        self.config = config48        self.rng = np.random.default_rng(seed)49        self.nodes = []50 51    def fit(self, x, y):52        x, y = np.asarray(x, np.float32), np.asarray(y, np.float32)53        self.nodes = []54        self._grow(x, y, np.arange(len(x)), 0)55        return self56 57    def _feature_count(self, total):58        value = self.config.max_features59        if value == "sqrt":60            return max(1, int(np.sqrt(total)))61        if value == "log2":62            return max(1, int(np.log2(total)))63        if isinstance(value, float):64            return max(1, min(total, int(np.ceil(value * total))))65        return max(1, min(total, int(value)))66 67    def _grow(self, x, y, indices, depth):68        node_id = len(self.nodes)69        self.nodes.append(None)70        leaf_value = y[indices].mean(axis=0).astype(np.float32)71        minimum = int(self.config.min_samples_leaf)72        if depth >= int(self.config.max_depth) or len(indices) < 2 * minimum:73            self.nodes[node_id] = {"value": leaf_value}74            return node_id75        features = self.rng.choice(x.shape[1], self._feature_count(x.shape[1]), replace=False)76        best = None77        parent_sse = float(np.square(y[indices] - leaf_value).sum())78        for feature in features:79            values = x[indices, feature]80            low, high = float(values.min()), float(values.max())81            if not low < high:82                continue83            thresholds = self.rng.uniform(low, high, int(self.config.split_candidates))84            for threshold in thresholds:85                mask = values <= threshold86                left, right = indices[mask], indices[~mask]87                if len(left) < minimum or len(right) < minimum:88                    continue89                left_mean, right_mean = y[left].mean(0), y[right].mean(0)90                loss = float(np.square(y[left] - left_mean).sum() +91                             np.square(y[right] - right_mean).sum())92                if best is None or loss < best[0]:93                    best = (loss, int(feature), float(threshold), left, right)94        if best is None or best[0] >= parent_sse - 1e-10:95            self.nodes[node_id] = {"value": leaf_value}96            return node_id97        _, feature, threshold, left, right = best98        self.nodes[node_id] = {"feature": feature, "threshold": threshold,99                               "left": self._grow(x, y, left, depth + 1),100                               "right": self._grow(x, y, right, depth + 1)}101        return node_id102 103    def predict(self, x):104        outputs = []105        for row in np.asarray(x):106            node = self.nodes[0]107            while "value" not in node:108                node = self.nodes[node["left"] if row[node["feature"]] <= node["threshold"] else node["right"]]109            outputs.append(node["value"])110        return np.asarray(outputs, dtype=np.float32)111 112    def state_dict(self):113        return {"config": vars(self.config), "nodes": self.nodes}114 115    @classmethod116    def from_state_dict(cls, state):117        tree = cls(TreeConfig(**state["config"]))118        tree.nodes = state["nodes"]119        return tree120 121 122class JointRandomForestRegressor:123    """Bootstrap ensemble retaining inseparable multi-output leaf predictions."""124 125    def __init__(self, n_trees=2, seed=0, **tree_options):126        self.n_trees, self.seed = int(n_trees), int(seed)127        self.tree_config = TreeConfig(**tree_options)128        self.trees = []129 130    def fit(self, x, y):131        x, y = np.asarray(x, np.float32), np.asarray(y, np.float32)132        rng = np.random.default_rng(self.seed)133        self.trees = []134        for index in range(self.n_trees):135            bootstrap = rng.integers(0, len(x), size=len(x))136            tree = ExtraRandomRegressionTree(self.tree_config, self.seed + 1009 * (index + 1))137            self.trees.append(tree.fit(x[bootstrap], y[bootstrap]))138        return self139 140    def predict(self, x):141        if not self.trees:142            raise RuntimeError("forest is not fitted")143        return np.mean([tree.predict(x) for tree in self.trees], axis=0, dtype=np.float32)144 145    def state_dict(self):146        return {"n_trees": self.n_trees, "seed": self.seed,147                "tree_config": vars(self.tree_config),148                "trees": [tree.state_dict() for tree in self.trees]}149 150    @classmethod151    def from_state_dict(cls, state):152        forest = cls(state["n_trees"], state["seed"], **state["tree_config"])153        forest.trees = [ExtraRandomRegressionTree.from_state_dict(item) for item in state["trees"]]154        return forest155 156 157class StandardizedForest:158    """Block-standardized wrapper; one scalar mean/std is used per variable block."""159 160    def __init__(self, forest, input_slices, output_slices, nonnegative_slice=None):161        self.forest = forest162        self.input_slices, self.output_slices = input_slices, output_slices163        self.nonnegative_slice = nonnegative_slice164        self.statistics = {}165 166    @staticmethod167    def _statistics(array, slices):168        means, stds = np.zeros(array.shape[1], np.float32), np.ones(array.shape[1], np.float32)169        for start, stop in slices:170            mean = float(array[:, start:stop].mean())171            std = max(float(array[:, start:stop].std()), 1e-6)172            means[start:stop], stds[start:stop] = mean, std173        return means, stds174 175    def fit(self, x, y):176        x = _check_array("inputs", x, self.input_slices[-1][1])177        y = _check_array("targets", y, self.output_slices[-1][1])178        x_mean, x_std = self._statistics(x, self.input_slices)179        y_mean, y_std = self._statistics(y, self.output_slices)180        self.statistics = {"input_mean": x_mean, "input_std": x_std,181                           "output_mean": y_mean, "output_std": y_std}182        self.forest.fit((x - x_mean) / x_std, (y - y_mean) / y_std)183        return self184 185    def predict(self, x):186        x = _check_array("inputs", x, len(self.statistics["input_mean"]))187        prediction = self.forest.predict((x - self.statistics["input_mean"]) / self.statistics["input_std"])188        prediction = prediction * self.statistics["output_std"] + self.statistics["output_mean"]189        if self.nonnegative_slice is not None:190            prediction[:, self.nonnegative_slice[0]:self.nonnegative_slice[1]] = np.maximum(191                prediction[:, self.nonnegative_slice[0]:self.nonnegative_slice[1]], 0.0)192        return prediction.astype(np.float32)193 194    def state_dict(self):195        return {"forest": self.forest.state_dict(), "input_slices": self.input_slices,196                "output_slices": self.output_slices, "nonnegative_slice": self.nonnegative_slice,197                "statistics": self.statistics}198 199    @classmethod200    def from_state_dict(cls, state):201        model = cls(JointRandomForestRegressor.from_state_dict(state["forest"]),202                    state["input_slices"], state["output_slices"], state["nonnegative_slice"])203        model.statistics = state["statistics"]204        return model205 206 207def build_pair(config, seed):208    options = config["engineering"]209    common = {"n_trees": options["trees"], "max_depth": options["max_depth"],210              "min_samples_leaf": options["min_samples_leaf"],211              "max_features": options["max_features"], "split_candidates": options["split_candidates"]}212    tend = StandardizedForest(JointRandomForestRegressor(seed=seed, **common),213                              [(0, 48), (48, 96), (96, 144), (144, 145)],214                              [(0, 48), (48, 96), (96, 144)])215    diff = StandardizedForest(JointRandomForestRegressor(seed=seed + 1, **common),216                              [(0, 15), (15, 30), (30, 45), (45, 60), (60, 61), (61, 62)],217                              [(0, 15), (15, 16), (16, 17)], nonnegative_slice=(0, 15))218    return {"rf_tend": tend, "rf_diff": diff}219 220 221def load_models(checkpoint):222    if checkpoint.get("format_version") != FORMAT_VERSION or checkpoint.get("model_name") != MODEL_NAME:223        raise ValueError("incompatible checkpoint model/format_version")224    if not isinstance(checkpoint.get("model"), dict) or set(checkpoint["model"]) != set(SCALES):225        raise ValueError("checkpoint model must contain all four scale forest states")226    expected = {"rf_tend_input": 145, "rf_tend_output": 144,227                "rf_diff_input": 62, "rf_diff_output": 17}228    if checkpoint.get("model_config", {}).get("dimensions") != expected:229        raise ValueError("checkpoint model_config dimensions are incompatible")230    return {scale: {name: StandardizedForest.from_state_dict(state)231                    for name, state in pair.items()}232            for scale, pair in checkpoint["model"].items()}233