zhangrenchao commited on
Commit
b11ef36
·
verified ·
1 Parent(s): a31b7c5

Publish MeteoNorm-RF reproduction

Browse files
.gitattributes CHANGED
@@ -1,35 +1,3 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
  *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.pkl filter=lfs diff=lfs merge=lfs -text
2
+ *.npz filter=lfs diff=lfs merge=lfs -text
3
+ *.png binary
 
 
 
 
 
 
 
 
 
 
 
 
conf/config.yaml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 2019
2
+ data:
3
+ path: data/meteonorm_rf_hourly.npz
4
+ synthetic_days: 30
5
+ start_day_of_year: 1
6
+ stations: 12
7
+ pollutants: [PM2.5, PM10, NO2, SO2, O3, CO]
8
+ frequency: hourly
9
+ train_fraction: 0.70
10
+ model:
11
+ engineering:
12
+ trees: 12
13
+ max_depth: 8
14
+ min_samples_leaf: 8
15
+ max_features: sqrt
16
+ split_candidates: 12
17
+ paper_protocol:
18
+ trees: hundreds
19
+ normalization_resamples: 1000
20
+ normalization:
21
+ engineering_resamples: 32
22
+ season_window_days: 14
23
+ match_hour: true
24
+ preserve_time_features: [ttrend, day_of_year, weekend, hour]
25
+ paths:
26
+ checkpoint: result/checkpoints/meteonorm_rf.pkl
27
+ training_metrics: result/training/metrics.json
28
+ inference: result/output/predictions.npz
29
+ evaluation_dir: result/evaluation
config.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"model_name":"MeteoNorm-RF","model_type":"meteonorm_rf","architectures":["MeteoNormRF"],"framework":"NumPy/PyTorch","domain":"air-quality","task":"meteorological-normalization","implementation":{"entry_point":"model/meteonorm_rf.py","scope":"core-method full-feature reduced-sample engineering reproduction","train_script":"scripts/train.py","inference_script":"scripts/inference.py","evaluation_script":"scripts/result.py","synthetic_data_script":"scripts/fake_data.py"},"architecture":{"input_features":21,"outputs":6,"engineering_trees":12,"paper_trees":"hundreds"},"data":{"dataset":"Beijing CNEM hourly air quality and meteorology","stations":12,"pollutants":["PM2.5","PM10","NO2","SO2","O3","CO"],"frequency":"hourly","synthetic":true},"configuration_sources":["conf/config.yaml","model/meteonorm_rf.py","scripts/fake_data.py","scripts/train.py","scripts/inference.py","scripts/result.py"]}
model/meteonorm_rf.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure NumPy multi-output random forest for Beijing air-quality normalization."""
2
+
3
+ from dataclasses import dataclass
4
+ import pickle
5
+
6
+ import numpy as np
7
+
8
+
9
+ FORMAT_VERSION = "meteonorm_rf_v1"
10
+ MODEL_NAME = "MeteoNorm-RF"
11
+ POLLUTANTS = ("PM2.5", "PM10", "NO2", "SO2", "O3", "CO")
12
+ STATIONS = ("dongsi", "tiantan", "guanyuan", "wanshouxigong", "aotizhongxin",
13
+ "nongzhanguan", "wanliu", "beibuxinqu", "zhiwuyuan", "fengtaihuayuan",
14
+ "yungang", "gucheng")
15
+ BASE_FEATURES = ("ttrend", "day_of_year", "weekend", "hour", "wind_speed",
16
+ "wind_direction", "pressure", "temperature", "relative_humidity")
17
+ FEATURE_NAMES = BASE_FEATURES + tuple(f"station={name}" for name in STATIONS)
18
+
19
+
20
+ def encode_features(data):
21
+ """Encode station as an explicit 12-column one-hot categorical feature."""
22
+ station = np.asarray(data["station_id"], dtype=np.int64)
23
+ if np.any((station < 0) | (station >= len(STATIONS))):
24
+ raise ValueError("station_id must be in [0, 11]")
25
+ numeric = np.column_stack([np.asarray(data[name], dtype=np.float32)
26
+ for name in BASE_FEATURES])
27
+ one_hot = np.eye(len(STATIONS), dtype=np.float32)[station]
28
+ result = np.concatenate([numeric, one_hot], axis=1)
29
+ if result.shape[1] != len(FEATURE_NAMES) or not np.isfinite(result).all():
30
+ raise ValueError("features must be finite and follow the 21-column protocol")
31
+ return result.astype(np.float32)
32
+
33
+
34
+ @dataclass
35
+ class TreeConfig:
36
+ max_depth: int = 8
37
+ min_samples_leaf: int = 8
38
+ max_features: object = "sqrt"
39
+ split_candidates: int = 12
40
+
41
+
42
+ class RegressionTree:
43
+ def __init__(self, config, seed=0):
44
+ self.config = config
45
+ self.rng = np.random.default_rng(seed)
46
+ self.nodes = []
47
+
48
+ def _feature_count(self, total):
49
+ value = self.config.max_features
50
+ if value == "sqrt":
51
+ return max(1, int(np.sqrt(total)))
52
+ if isinstance(value, float):
53
+ return max(1, min(total, int(np.ceil(total * value))))
54
+ return max(1, min(total, int(value)))
55
+
56
+ def fit(self, x, y):
57
+ self.nodes = []
58
+ self._grow(np.asarray(x, np.float32), np.asarray(y, np.float32),
59
+ np.arange(len(x)), 0)
60
+ return self
61
+
62
+ def _grow(self, x, y, indices, depth):
63
+ node_id = len(self.nodes)
64
+ self.nodes.append(None)
65
+ value = y[indices].mean(0).astype(np.float32)
66
+ minimum = int(self.config.min_samples_leaf)
67
+ if depth >= self.config.max_depth or len(indices) < 2 * minimum:
68
+ self.nodes[node_id] = {"value": value}
69
+ return node_id
70
+ best = None
71
+ features = self.rng.choice(x.shape[1], self._feature_count(x.shape[1]), replace=False)
72
+ for feature in features:
73
+ values = x[indices, feature]
74
+ low, high = float(values.min()), float(values.max())
75
+ if low >= high:
76
+ continue
77
+ for threshold in self.rng.uniform(low, high, self.config.split_candidates):
78
+ mask = values <= threshold
79
+ left, right = indices[mask], indices[~mask]
80
+ if len(left) < minimum or len(right) < minimum:
81
+ continue
82
+ loss = (np.square(y[left] - y[left].mean(0)).sum() +
83
+ np.square(y[right] - y[right].mean(0)).sum())
84
+ if best is None or loss < best[0]:
85
+ best = (float(loss), int(feature), float(threshold), left, right)
86
+ if best is None:
87
+ self.nodes[node_id] = {"value": value}
88
+ return node_id
89
+ _, feature, threshold, left, right = best
90
+ self.nodes[node_id] = {"feature": feature, "threshold": threshold,
91
+ "left": self._grow(x, y, left, depth + 1),
92
+ "right": self._grow(x, y, right, depth + 1)}
93
+ return node_id
94
+
95
+ def predict(self, x):
96
+ output = []
97
+ for row in np.asarray(x, np.float32):
98
+ node = self.nodes[0]
99
+ while "value" not in node:
100
+ branch = "left" if row[node["feature"]] <= node["threshold"] else "right"
101
+ node = self.nodes[node[branch]]
102
+ output.append(node["value"])
103
+ return np.asarray(output, dtype=np.float32)
104
+
105
+
106
+ class MultiOutputRandomForest:
107
+ def __init__(self, n_trees=12, seed=2019, **tree_options):
108
+ self.n_trees, self.seed = int(n_trees), int(seed)
109
+ self.tree_config = TreeConfig(**tree_options)
110
+ self.trees = []
111
+
112
+ def fit(self, x, y, tree_indices=None):
113
+ x, y = np.asarray(x, np.float32), np.asarray(y, np.float32)
114
+ indices = range(self.n_trees) if tree_indices is None else tree_indices
115
+ self.trees = []
116
+ for index in indices:
117
+ rng = np.random.default_rng(self.seed + 7919 * (index + 1))
118
+ sample = rng.integers(0, len(x), len(x))
119
+ tree = RegressionTree(self.tree_config, self.seed + 104729 * (index + 1))
120
+ self.trees.append((int(index), tree.fit(x[sample], y[sample])))
121
+ return self
122
+
123
+ def predict(self, x):
124
+ if not self.trees:
125
+ raise RuntimeError("forest is not fitted")
126
+ return np.mean([tree.predict(x) for _, tree in self.trees], axis=0, dtype=np.float32)
127
+
128
+ def state_dict(self):
129
+ return {"n_trees": self.n_trees, "seed": self.seed,
130
+ "tree_config": vars(self.tree_config),
131
+ "trees": [(index, tree.nodes) for index, tree in self.trees]}
132
+
133
+ @classmethod
134
+ def from_state_dict(cls, state):
135
+ model = cls(state["n_trees"], state["seed"], **state["tree_config"])
136
+ for index, nodes in state["trees"]:
137
+ tree = RegressionTree(model.tree_config)
138
+ tree.nodes = nodes
139
+ model.trees.append((index, tree))
140
+ return model
141
+
142
+
143
+ class MeteoNormRF:
144
+ def __init__(self, forest):
145
+ self.forest = forest
146
+ self.statistics = {}
147
+
148
+ def fit(self, x, y, tree_indices=None):
149
+ x, y = np.asarray(x, np.float32), np.asarray(y, np.float32)
150
+ self.statistics = {"x_mean": x.mean(0), "x_std": np.maximum(x.std(0), 1e-6),
151
+ "y_mean": y.mean(0), "y_std": np.maximum(y.std(0), 1e-6)}
152
+ self.forest.fit((x - self.statistics["x_mean"]) / self.statistics["x_std"],
153
+ (y - self.statistics["y_mean"]) / self.statistics["y_std"], tree_indices)
154
+ return self
155
+
156
+ def predict(self, x):
157
+ x = np.asarray(x, np.float32)
158
+ value = self.forest.predict((x - self.statistics["x_mean"]) / self.statistics["x_std"])
159
+ return np.maximum(value * self.statistics["y_std"] + self.statistics["y_mean"], 0.0)
160
+
161
+ def state_dict(self):
162
+ return {"forest": self.forest.state_dict(), "statistics": self.statistics}
163
+
164
+ @classmethod
165
+ def from_state_dict(cls, state):
166
+ model = cls(MultiOutputRandomForest.from_state_dict(state["forest"]))
167
+ model.statistics = state["statistics"]
168
+ return model
169
+
170
+
171
+ def merge_states(states):
172
+ base = states[0]
173
+ trees = [item for state in states for item in state["forest"]["trees"]]
174
+ base["forest"]["trees"] = sorted(trees, key=lambda item: item[0])
175
+ return base
176
+
177
+
178
+ def save_checkpoint(path, model, metadata):
179
+ with open(path, "wb") as stream:
180
+ pickle.dump({"format_version": FORMAT_VERSION, "model_name": MODEL_NAME,
181
+ "model": model.state_dict(), "metadata": metadata}, stream)
182
+
183
+
184
+ def load_checkpoint(path):
185
+ with open(path, "rb") as stream:
186
+ state = pickle.load(stream)
187
+ if state.get("format_version") != FORMAT_VERSION or state.get("model_name") != MODEL_NAME:
188
+ raise ValueError("incompatible checkpoint")
189
+ return MeteoNormRF.from_state_dict(state["model"]), state["metadata"]
scripts/fake_data.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate compact hourly data retaining all 12 stations and six pollutants."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import yaml
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(ROOT))
11
+ from model.meteonorm_rf import BASE_FEATURES, FORMAT_VERSION, POLLUTANTS, STATIONS
12
+
13
+
14
+ def main():
15
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
16
+ rng = np.random.default_rng(int(config["seed"]))
17
+ days = int(config["data"]["synthetic_days"])
18
+ hours = np.tile(np.arange(days * 24), len(STATIONS))
19
+ station = np.repeat(np.arange(len(STATIONS)), days * 24)
20
+ day = hours // 24
21
+ hour = hours % 24
22
+ doy = day + int(config["data"]["start_day_of_year"])
23
+ weekend = ((day + 1) % 7 >= 5).astype(np.float32)
24
+ seasonal = np.sin(2 * np.pi * (doy - 172) / 365.25)
25
+ diurnal = np.sin(2 * np.pi * (hour - 14) / 24)
26
+ synoptic = np.sin(2 * np.pi * day / 6.5 + station * 0.17)
27
+ wind_speed = np.maximum(0.2, 2.7 + 1.1 * synoptic + rng.normal(0, .55, len(hours)))
28
+ wind_direction = np.mod(185 + 75 * synoptic + station * 9 + rng.normal(0, 28, len(hours)), 360)
29
+ temperature = 13 + 12 * seasonal + 5.5 * diurnal + rng.normal(0, 1.4, len(hours))
30
+ rh = np.clip(52 - 18 * seasonal - 10 * diurnal - 3 * wind_speed + rng.normal(0, 7, len(hours)), 15, 98)
31
+ pressure = 1013 - .32 * temperature + 4 * np.cos(2 * np.pi * day / 8) + rng.normal(0, 1.8, len(hours))
32
+ ttrend = 2013.0 + hours / (24 * 365.25)
33
+ emission = 1.0 - .22 * day / max(days - 1, 1)
34
+ stagnation = np.exp(-wind_speed / 3.2) * (0.65 + rh / 150)
35
+ rush = ((hour >= 7) & (hour <= 10)) | ((hour >= 17) & (hour <= 21))
36
+ station_factor = 0.82 + station * .025
37
+ pm25 = (22 + 78 * stagnation + 16 * rush) * emission * station_factor
38
+ pm10 = 28 + 1.22 * pm25 + 7 * wind_speed
39
+ no2 = (15 + 37 * stagnation + 19 * rush) * emission * station_factor
40
+ so2 = (4 + 25 * stagnation) * emission ** 1.8 * (1.1 - .15 * seasonal)
41
+ o3 = np.maximum(3, 30 + 29 * np.maximum(diurnal, -.4) + 1.2 * temperature - .28 * no2)
42
+ co = (0.25 + .012 * pm25 + .008 * no2) * emission
43
+ pollution = np.column_stack([pm25, pm10, no2, so2, o3, co])
44
+ pollution *= rng.lognormal(0, .09, pollution.shape)
45
+ output = ROOT / config["data"]["path"]
46
+ output.parent.mkdir(parents=True, exist_ok=True)
47
+ np.savez_compressed(output, format_version=np.array(FORMAT_VERSION),
48
+ station_names=np.asarray(STATIONS), pollutant_names=np.asarray(POLLUTANTS),
49
+ station_id=station.astype(np.int16), timestamp_hour=hours.astype(np.int32),
50
+ ttrend=ttrend.astype(np.float32), day_of_year=doy.astype(np.float32),
51
+ weekend=weekend, hour=hour.astype(np.float32),
52
+ wind_speed=wind_speed.astype(np.float32), wind_direction=wind_direction.astype(np.float32),
53
+ pressure=pressure.astype(np.float32), temperature=temperature.astype(np.float32),
54
+ relative_humidity=rh.astype(np.float32), pollution=pollution.astype(np.float32),
55
+ feature_names=np.asarray(BASE_FEATURES))
56
+ print(f"data={output.relative_to(ROOT)} rows={len(hours)} stations=12 pollutants=6 hourly_days={days}")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Predict the test set and perform hour/season-window weather normalization."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import yaml
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(ROOT))
11
+ from model.meteonorm_rf import BASE_FEATURES, encode_features, load_checkpoint
12
+
13
+
14
+ def circular_day_distance(a, b):
15
+ distance = np.abs(a - b)
16
+ return np.minimum(distance, 365 - distance)
17
+
18
+
19
+ def main():
20
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
21
+ data = np.load(ROOT / config["data"]["path"])
22
+ model, metadata = load_checkpoint(ROOT / config["paths"]["checkpoint"])
23
+ x = encode_features(data)
24
+ test = np.asarray(metadata["test_indices"], dtype=np.int64)
25
+ observed = data["pollution"][test]
26
+ predicted = model.predict(x[test])
27
+ repeats = int(config["normalization"]["engineering_resamples"])
28
+ window = int(config["normalization"]["season_window_days"])
29
+ rng = np.random.default_rng(int(config["seed"]) + 17)
30
+ normalized = np.empty_like(predicted)
31
+ weather_columns = [BASE_FEATURES.index(name) for name in
32
+ ("wind_speed", "wind_direction", "pressure", "temperature", "relative_humidity")]
33
+ all_hour, all_doy = data["hour"], data["day_of_year"]
34
+ for output_index, row_index in enumerate(test):
35
+ candidates = np.flatnonzero((all_hour == all_hour[row_index]) &
36
+ (circular_day_distance(all_doy, all_doy[row_index]) <= window))
37
+ draws = rng.choice(candidates, repeats, replace=True)
38
+ replicated = np.repeat(x[row_index:row_index + 1], repeats, axis=0)
39
+ replicated[:, weather_columns] = x[draws][:, weather_columns]
40
+ # ttrend, day-of-year, weekend, hour, and station remain at the target time/station.
41
+ normalized[output_index] = model.predict(replicated).mean(0)
42
+ output = ROOT / config["paths"]["inference"]
43
+ output.parent.mkdir(parents=True, exist_ok=True)
44
+ np.savez_compressed(output, test_indices=test, observed=observed, predicted=predicted,
45
+ normalized=normalized, ttrend=data["ttrend"][test],
46
+ day_of_year=data["day_of_year"][test], hour=data["hour"][test],
47
+ station_id=data["station_id"][test], pollutant_names=data["pollutant_names"],
48
+ resamples=np.array(repeats), season_window_days=np.array(window))
49
+ print(f"predictions={output.relative_to(ROOT)} test_rows={len(test)} normalization_resamples={repeats}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
scripts/result.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compute paper metrics, Theil-Sen trends, and the comparison figure."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import matplotlib
7
+ matplotlib.use("Agg")
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import yaml
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+
14
+
15
+ def metrics(observed, predicted):
16
+ error = predicted - observed
17
+ denominator = np.maximum(np.sum(observed, axis=0), 1e-12)
18
+ r2_denominator = np.maximum(np.sum((observed - observed.mean(0)) ** 2, axis=0), 1e-12)
19
+ ioa_denominator = np.maximum(np.sum((np.abs(predicted - observed.mean(0)) +
20
+ np.abs(observed - observed.mean(0))) ** 2, axis=0), 1e-12)
21
+ return {"RMSE": np.sqrt(np.mean(error ** 2, axis=0)),
22
+ "r2": 1 - np.sum(error ** 2, axis=0) / r2_denominator,
23
+ "FAC2": np.mean((predicted / np.maximum(observed, 1e-12) >= .5) &
24
+ (predicted / np.maximum(observed, 1e-12) <= 2), axis=0),
25
+ "MB": np.mean(error, axis=0), "MGE": np.mean(np.abs(error), axis=0),
26
+ "NMB": np.sum(error, axis=0) / denominator,
27
+ "NMGE": np.sum(np.abs(error), axis=0) / denominator,
28
+ "COE": 1 - np.sum(np.abs(error), axis=0) /
29
+ np.maximum(np.sum(np.abs(observed - observed.mean(0)), axis=0), 1e-12),
30
+ "IOA": 1 - np.sum(error ** 2, axis=0) / ioa_denominator}
31
+
32
+
33
+ def theil_sen(x, y, max_pairs=200000, seed=0):
34
+ """Median pairwise slope, with deterministic subsampling for large series."""
35
+ x, y = np.asarray(x, float), np.asarray(y, float)
36
+ total = len(x) * (len(x) - 1) // 2
37
+ if total <= max_pairs:
38
+ slopes = [(y[j] - y[i]) / (x[j] - x[i]) for i in range(len(x) - 1)
39
+ for j in range(i + 1, len(x)) if x[j] != x[i]]
40
+ else:
41
+ rng = np.random.default_rng(seed)
42
+ left = rng.integers(0, len(x) - 1, max_pairs)
43
+ right = rng.integers(left + 1, len(x), max_pairs)
44
+ valid = x[right] != x[left]
45
+ slopes = (y[right[valid]] - y[left[valid]]) / (x[right[valid]] - x[left[valid]])
46
+ slope = float(np.median(slopes))
47
+ return slope, float(np.median(y - slope * x))
48
+
49
+
50
+ def main():
51
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
52
+ data = np.load(ROOT / config["paths"]["inference"])
53
+ names = [str(value) for value in data["pollutant_names"]]
54
+ scores = metrics(data["observed"], data["predicted"])
55
+ report = {"metrics": {name: {metric: float(values[i]) for metric, values in scores.items()}
56
+ for i, name in enumerate(names)}, "theil_sen_per_year": {}}
57
+ trends = []
58
+ for i, name in enumerate(names):
59
+ raw = theil_sen(data["ttrend"], data["observed"][:, i], seed=i)
60
+ normalized = theil_sen(data["ttrend"], data["normalized"][:, i], seed=100 + i)
61
+ report["theil_sen_per_year"][name] = {"observed_slope": raw[0],
62
+ "normalized_slope": normalized[0]}
63
+ trends.append((raw[0], normalized[0]))
64
+ output = ROOT / config["paths"]["evaluation_dir"]
65
+ output.mkdir(parents=True, exist_ok=True)
66
+ (output / "metrics.json").write_text(json.dumps(report, indent=2) + "\n")
67
+ figure, axes = plt.subplots(2, 1, figsize=(11, 8), gridspec_kw={"height_ratios": [2, 1]})
68
+ order = np.argsort(data["ttrend"])
69
+ sample = order[::max(1, len(order) // 600)]
70
+ axes[0].plot(data["ttrend"][sample], data["observed"][sample, 0], ".", alpha=.25, label="observed PM2.5")
71
+ axes[0].plot(data["ttrend"][sample], data["normalized"][sample, 0], ".", alpha=.4, label="weather-normalized PM2.5")
72
+ axes[0].set(ylabel="Concentration (ug m-3)", title="Observed and meteorologically normalized test samples")
73
+ axes[0].legend()
74
+ positions = np.arange(len(names)); width = .36
75
+ axes[1].bar(positions - width / 2, [v[0] for v in trends], width, label="observed")
76
+ axes[1].bar(positions + width / 2, [v[1] for v in trends], width, label="normalized")
77
+ axes[1].axhline(0, color="black", linewidth=.7); axes[1].set_xticks(positions, names)
78
+ axes[1].set(ylabel="Theil-Sen slope per year", title="Robust concentration trends")
79
+ axes[1].legend(); figure.tight_layout(); figure.savefig(output / "comparison.png", dpi=160)
80
+ plt.close(figure)
81
+ print(f"evaluation={output.relative_to(ROOT)} pollutants={len(names)}")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
scripts/train.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train a pure NumPy RF; torchrun ranks build disjoint tree subsets."""
2
+
3
+ import json
4
+ import os
5
+ import pickle
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import yaml
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(ROOT))
14
+ from model.meteonorm_rf import (FEATURE_NAMES, FORMAT_VERSION, MODEL_NAME,
15
+ MultiOutputRandomForest, MeteoNormRF,
16
+ encode_features, merge_states, save_checkpoint)
17
+
18
+
19
+ def main():
20
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
21
+ seed = int(config["seed"])
22
+ data = np.load(ROOT / config["data"]["path"])
23
+ x, y = encode_features(data), data["pollution"].astype(np.float32)
24
+ rng = np.random.default_rng(seed)
25
+ order = rng.permutation(len(x))
26
+ cut = int(float(config["data"]["train_fraction"]) * len(order))
27
+ train_indices, test_indices = order[:cut], order[cut:]
28
+ world, rank = int(os.environ.get("WORLD_SIZE", "1")), int(os.environ.get("RANK", "0"))
29
+ options = config["model"]["engineering"]
30
+ trees = int(options["trees"])
31
+ assigned = list(range(rank, trees, world))
32
+ forest = MultiOutputRandomForest(n_trees=trees, seed=seed,
33
+ max_depth=int(options["max_depth"]),
34
+ min_samples_leaf=int(options["min_samples_leaf"]),
35
+ max_features=options["max_features"],
36
+ split_candidates=int(options["split_candidates"]))
37
+ model = MeteoNormRF(forest).fit(x[train_indices], y[train_indices], assigned)
38
+ checkpoint = ROOT / config["paths"]["checkpoint"]
39
+ checkpoint.parent.mkdir(parents=True, exist_ok=True)
40
+ shard = checkpoint.with_suffix(f".rank{rank}.pkl")
41
+ with open(shard, "wb") as stream:
42
+ pickle.dump(model.state_dict(), stream)
43
+ if world > 1:
44
+ import torch
45
+ torch.distributed.init_process_group("gloo")
46
+ torch.distributed.barrier()
47
+ if rank == 0:
48
+ states = []
49
+ for item in range(world):
50
+ with open(checkpoint.with_suffix(f".rank{item}.pkl"), "rb") as stream:
51
+ states.append(pickle.load(stream))
52
+ merged = MeteoNormRF.from_state_dict(merge_states(states))
53
+ metadata = {"feature_names": FEATURE_NAMES, "train_indices": train_indices,
54
+ "test_indices": test_indices, "split": "seeded random 70/30",
55
+ "paper_trees": config["paper_protocol"]["trees"], "engineering_trees": trees}
56
+ save_checkpoint(checkpoint, merged, metadata)
57
+ metrics = ROOT / config["paths"]["training_metrics"]
58
+ metrics.parent.mkdir(parents=True, exist_ok=True)
59
+ metrics.write_text(json.dumps({"format_version": FORMAT_VERSION, "model": MODEL_NAME,
60
+ "rows": len(x), "train_rows": cut, "test_rows": len(x) - cut,
61
+ "trees": trees, "world_size": world}, indent=2) + "\n")
62
+ for item in range(world):
63
+ checkpoint.with_suffix(f".rank{item}.pkl").unlink()
64
+ print(f"checkpoint={checkpoint.relative_to(ROOT)} trees={trees} train={cut} test={len(x)-cut}")
65
+ if world > 1:
66
+ torch.distributed.barrier()
67
+ torch.distributed.destroy_process_group()
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
weight/.gitkeep ADDED
File without changes