Publish MeteoNorm-RF reproduction
Browse files- .gitattributes +2 -34
- conf/config.yaml +29 -0
- config.json +1 -0
- model/meteonorm_rf.py +189 -0
- scripts/fake_data.py +60 -0
- scripts/inference.py +53 -0
- scripts/result.py +85 -0
- scripts/train.py +71 -0
- weight/.gitkeep +0 -0
.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 |
-
*.
|
| 23 |
-
*.
|
| 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
|