Sarikaa-Sridhar commited on
Commit
98024ab
·
verified ·
1 Parent(s): 78253cb

Upload 21 files

Browse files
training_code/config/__init__.py ADDED
File without changes
training_code/config/config.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, asdict
3
+ from pathlib import Path
4
+ import json
5
+
6
+ @dataclass
7
+ class TrainConfig:
8
+ mode: str = "train_eval" # train_eval | train | eval | predict
9
+
10
+ train_file: Path | None = None
11
+ val_file: Path | None = None
12
+ test_file: Path | None = None
13
+ predict_file: Path | None = None
14
+ checkpoint_path: Path | None = None
15
+ output_csv: Path | None = None
16
+ single_sample_json: Path | None = None
17
+
18
+ weather_vars: list[str] | None = None
19
+ soil_vars: list[str] | None = None
20
+
21
+ crop_col: str = "crop"
22
+ yield_col: str = "yield"
23
+ field_col: str = "farm_field"
24
+ year_col: str = "year"
25
+
26
+ years: str | None = None
27
+ crop: str | None = None
28
+ time_agg: str = "weekly_cumulative"
29
+
30
+ train_cutoffs: list[int] | None = None
31
+ eval_cutoffs: list[int] | None = None
32
+ predict_cutoff: int | None = None
33
+
34
+ d_model: int = 64
35
+ nhead: int = 4
36
+ num_layers: int = 4
37
+ dim_ff: int = 128
38
+ dropout: float = 0.4
39
+ pool: str = "last"
40
+ use_crop: bool = True
41
+ crop_emb_dim: int = 8
42
+
43
+ epochs: int = 10
44
+ lr: float = 1e-4
45
+ batch_size: int = 64
46
+ weight_decay: float = 1e-3
47
+ seed: int = 1234
48
+ early_stop_patience: int = 3
49
+
50
+ split_strategy: str = "field" # field | random | none
51
+ val_split: float = 0.2
52
+ test_split: float = 0.2
53
+
54
+ expt_name: str = "test"
55
+ out_dir: Path = Path("outputs")
56
+ log_dir: Path = Path("runs")
57
+
58
+ def validate(self) -> None:
59
+ if self.mode in {"train", "train_eval"}:
60
+ if self.train_file is None:
61
+ raise ValueError("train_file is required for training.")
62
+ if not self.weather_vars:
63
+ raise ValueError("weather_vars is required for training.")
64
+ if not self.soil_vars:
65
+ raise ValueError("soil_vars is required for training.")
66
+
67
+ if self.mode in {"eval", "predict"} and self.checkpoint_path is None:
68
+ raise ValueError("checkpoint_path is required for eval/predict.")
69
+
70
+ if self.mode == "eval" and self.test_file is None:
71
+ raise ValueError("test_file is required for eval.")
72
+
73
+ if self.mode == "predict":
74
+ if self.predict_file is None and self.single_sample_json is None:
75
+ raise ValueError("predict_file or single_sample_json is required.")
76
+ if self.predict_cutoff is None:
77
+ raise ValueError("predict_cutoff is required for predict.")
78
+
79
+ def save(self) -> None:
80
+ self.out_dir.mkdir(parents=True, exist_ok=True)
81
+ with open(self.out_dir / "config.json", "w") as f:
82
+ json.dump(asdict(self), f, indent=2, default=str)
training_code/data/__init__.py ADDED
File without changes
training_code/data/dataset.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ import numpy as np
4
+ import pandas as pd
5
+ import torch
6
+ from torch.utils.data import Dataset
7
+
8
+ from data.preprocessing import (
9
+ load_table,
10
+ decode_bytes_in_object_cols,
11
+ find_daily_cols,
12
+ daily_to_cumulative_weekly,
13
+ DEFAULT_WEATHER_AGG_RULES,
14
+ )
15
+
16
+ class YieldDataset(Dataset):
17
+ def __init__(
18
+ self,
19
+ data_file: str | Path,
20
+ weather_vars: list[str],
21
+ soil_vars: list[str],
22
+ split: str = "all",
23
+ seed: int = 1234,
24
+ crop: str | None = None,
25
+ years: str | None = None,
26
+ crop_col: str = "crop",
27
+ yield_col: str = "yield",
28
+ field_col: str = "farm_field",
29
+ year_col: str = "year",
30
+ time_agg: str = "weekly_cumulative",
31
+ split_strategy: str = "field",
32
+ val_split: float = 0.2,
33
+ test_split: float = 0.2,
34
+ require_yield: bool = True,
35
+ ):
36
+ self.weather_vars = weather_vars
37
+ self.soil_vars = soil_vars
38
+ self.crop_col = crop_col
39
+ self.yield_col = yield_col
40
+ self.field_col = field_col
41
+ self.year_col = year_col
42
+ self.time_agg = time_agg
43
+ self.crop_map = {"corn": 0, "maize": 0, "soybean": 1, "soy": 1}
44
+
45
+ self.w_mean = None
46
+ self.w_std = None
47
+ self.s_mean = None
48
+ self.s_std = None
49
+
50
+ df = load_table(data_file)
51
+ df = decode_bytes_in_object_cols(df)
52
+ df.columns = [str(c).strip() for c in df.columns]
53
+
54
+ if yield_col != "yield" and yield_col in df.columns:
55
+ df = df.rename(columns={yield_col: "yield"})
56
+ self.yield_col = "yield"
57
+
58
+ required = [crop_col, field_col, year_col] + soil_vars
59
+ if require_yield:
60
+ required.append(self.yield_col)
61
+
62
+ missing = [c for c in required if c not in df.columns]
63
+ if missing:
64
+ raise ValueError(f"Missing required columns: {missing[:20]}")
65
+
66
+ if require_yield:
67
+ df[self.yield_col] = pd.to_numeric(df[self.yield_col], errors="coerce")
68
+ df = df.dropna(subset=[self.yield_col]).reset_index(drop=True)
69
+ else:
70
+ if self.yield_col not in df.columns:
71
+ df[self.yield_col] = np.nan
72
+
73
+ df[crop_col] = df[crop_col].astype(str).str.strip().str.lower()
74
+ if crop:
75
+ df = df[df[crop_col] == crop.strip().lower()].reset_index(drop=True)
76
+
77
+ if years:
78
+ year_list = [int(y.strip()) for y in years.split(",") if y.strip()]
79
+ df = df[df[year_col].isin(year_list)].reset_index(drop=True)
80
+
81
+ self.weather_cols_by_var = {}
82
+
83
+ for v in weather_vars:
84
+ cols = find_daily_cols(df, v)
85
+
86
+ if not cols:
87
+ examples = [c for c in df.columns if str(c).startswith(v)][:10]
88
+ raise ValueError(
89
+ f"No indexed columns found for weather var '{v}'. "
90
+ f"Examples: {examples}"
91
+ )
92
+
93
+ self.weather_cols_by_var[v] = cols
94
+
95
+
96
+ if self.time_agg == "weekly":
97
+ # Input data is already weekly.
98
+ self.K = len(
99
+ self.weather_cols_by_var[weather_vars[0]]
100
+ )
101
+
102
+ elif self.time_agg == "weekly_cumulative":
103
+ # Existing behavior for daily input.
104
+ self.K = len(
105
+ daily_to_cumulative_weekly(
106
+ df.loc[
107
+ 0,
108
+ self.weather_cols_by_var[weather_vars[0]]
109
+ ].to_numpy(dtype=np.float32),
110
+ agg=DEFAULT_WEATHER_AGG_RULES.get(
111
+ weather_vars[0],
112
+ "mean",
113
+ ),
114
+ )
115
+ )
116
+
117
+ else:
118
+ raise ValueError(
119
+ "time_agg must be 'weekly' or 'weekly_cumulative'"
120
+ )
121
+
122
+ df = df.reset_index(drop=True)
123
+ self.df_full = df
124
+ self.indices = self._make_split_indices(df, split, seed, split_strategy, val_split, test_split)
125
+
126
+ self.df = df
127
+ self.soil_arr = df[soil_vars].to_numpy(np.float32)
128
+ self.crop_arr = df[crop_col].astype(str).str.strip().str.lower().to_numpy()
129
+ self.y_arr = df[self.yield_col].to_numpy(np.float32)
130
+
131
+ def _make_split_indices(self, df, split, seed, split_strategy, val_split, test_split):
132
+ rng = np.random.default_rng(seed)
133
+
134
+ if split == "all" or split_strategy == "none":
135
+ return np.arange(len(df))
136
+
137
+ if split_strategy == "field":
138
+ fields = df[self.field_col].astype(str).unique()
139
+ rng.shuffle(fields)
140
+
141
+ n = len(fields)
142
+ n_test = max(1, int(test_split * n)) if n >= 3 else 0
143
+ n_val = max(1, int(val_split * n)) if n >= 3 else 0
144
+ n_train = n - n_val - n_test
145
+
146
+ train_fields = set(fields[:n_train])
147
+ val_fields = set(fields[n_train:n_train + n_val])
148
+ test_fields = set(fields[n_train + n_val:])
149
+
150
+ if split == "train":
151
+ keep = train_fields
152
+ elif split == "val":
153
+ keep = val_fields
154
+ elif split == "test":
155
+ keep = test_fields
156
+ else:
157
+ raise ValueError("split must be train, val, test, or all")
158
+
159
+ return df.index[df[self.field_col].astype(str).isin(keep)].to_numpy()
160
+
161
+ if split_strategy == "random":
162
+ idx = np.arange(len(df))
163
+ rng.shuffle(idx)
164
+ n = len(idx)
165
+ n_test = int(test_split * n)
166
+ n_val = int(val_split * n)
167
+ n_train = n - n_val - n_test
168
+
169
+ if split == "train":
170
+ return idx[:n_train]
171
+ if split == "val":
172
+ return idx[n_train:n_train + n_val]
173
+ if split == "test":
174
+ return idx[n_train + n_val:]
175
+
176
+ raise ValueError(f"Unknown split_strategy={split_strategy}")
177
+
178
+ def set_normalization(self, w_mean, w_std, s_mean, s_std):
179
+ self.w_mean = np.asarray(w_mean, dtype=np.float32)
180
+ self.w_std = np.asarray(w_std, dtype=np.float32)
181
+ self.s_mean = np.asarray(s_mean, dtype=np.float32)
182
+ self.s_std = np.asarray(s_std, dtype=np.float32)
183
+
184
+ def __len__(self):
185
+ return len(self.indices)
186
+
187
+ def __getitem__(self, idx):
188
+ ridx = int(self.indices[idx])
189
+ row = self.df.loc[ridx]
190
+
191
+ weather_vars = []
192
+
193
+ for v in self.weather_vars:
194
+
195
+ values = row[
196
+ self.weather_cols_by_var[v]
197
+ ].to_numpy(dtype=np.float32)
198
+
199
+ if self.time_agg == "weekly":
200
+
201
+ # Data is already weekly.
202
+ # Use the 52 weekly values exactly as supplied.
203
+ seq = values
204
+
205
+ elif self.time_agg == "weekly_cumulative":
206
+
207
+ # Existing behavior for datasets containing daily weather.
208
+ agg = DEFAULT_WEATHER_AGG_RULES.get(v, "mean")
209
+
210
+ seq = daily_to_cumulative_weekly(
211
+ values,
212
+ agg=agg,
213
+ week_len=7,
214
+ )
215
+
216
+ weather_vars.append(seq)
217
+
218
+
219
+ if self.time_agg == "weekly":
220
+
221
+ # [K, number_of_weather_variables]
222
+ weather = np.stack(
223
+ weather_vars,
224
+ axis=1,
225
+ ).astype(np.float32)
226
+
227
+ else:
228
+
229
+ # Existing daily -> weekly+cumulative representation
230
+ weather = np.stack(
231
+ weather_vars,
232
+ axis=1,
233
+ ).astype(np.float32)
234
+
235
+ weather = weather.reshape(
236
+ weather.shape[0],
237
+ -1,
238
+ ).astype(np.float32)
239
+
240
+ soil = self.soil_arr[ridx].astype(np.float32)
241
+
242
+ if self.w_mean is not None:
243
+ weather = np.where(np.isnan(weather), self.w_mean[None, :], weather)
244
+ weather = (weather - self.w_mean[None, :]) / self.w_std[None, :]
245
+
246
+ if self.s_mean is not None:
247
+ soil = np.where(np.isnan(soil), self.s_mean, soil)
248
+ soil = (soil - self.s_mean) / self.s_std
249
+
250
+ crop = str(self.crop_arr[ridx]).strip().lower()
251
+ crop_id = self.crop_map.get(crop, 0)
252
+
253
+ return {
254
+ "weather": torch.from_numpy(weather),
255
+ "soil": torch.from_numpy(soil),
256
+ "crop_id": torch.tensor(crop_id, dtype=torch.long),
257
+ "yield": torch.tensor(float(self.y_arr[ridx]), dtype=torch.float32),
258
+ "farm_field": str(row[self.field_col]),
259
+ "year": int(row[self.year_col]),
260
+ }
training_code/data/preprocessing.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import math
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+ DEFAULT_WEATHER_AGG_RULES = {
7
+ "dayl": "mean",
8
+ "prcp": "sum",
9
+ "srad": "mean",
10
+ "tmax": "mean",
11
+ "tmin": "mean",
12
+ "vp": "mean",
13
+ "tmean": "mean",
14
+ "gdd": "sum",
15
+ "precip_3day_avg_perday": "mean",
16
+ "precip_7day_avg_perday": "mean",
17
+ "precip_14day_avg_perday": "mean",
18
+ }
19
+
20
+ def load_table(path):
21
+ path = str(path)
22
+ if path.endswith(".h5") or path.endswith(".hdf5"):
23
+ return pd.read_hdf(path)
24
+ if path.endswith(".csv"):
25
+ return pd.read_csv(path)
26
+ if path.endswith(".parquet"):
27
+ return pd.read_parquet(path)
28
+ raise ValueError(f"Unsupported file type: {path}")
29
+
30
+ def decode_bytes_in_object_cols(df: pd.DataFrame) -> pd.DataFrame:
31
+ out = df.copy()
32
+ for c in out.select_dtypes(include=["object"]).columns:
33
+ if len(out) > 0 and isinstance(out[c].iloc[0], (bytes, bytearray)):
34
+ out[c] = out[c].str.decode("utf-8")
35
+ return out
36
+
37
+ def find_daily_cols(df: pd.DataFrame, var: str) -> list[str]:
38
+ pat_bracket = re.compile(rf"^{re.escape(var)}.*?_\[(\d+)\]$")
39
+ pat_plain = re.compile(rf"^{re.escape(var)}.*?_(\d+)$")
40
+ hits = []
41
+ for c in df.columns:
42
+ s = str(c)
43
+ m = pat_bracket.match(s) or pat_plain.match(s)
44
+ if m:
45
+ hits.append((int(m.group(1)), c))
46
+ hits.sort(key=lambda x: x[0])
47
+ return [c for _, c in hits]
48
+
49
+ # def daily_to_cumulative_weekly(daily: np.ndarray, agg: str, week_len: int = 7) -> np.ndarray:
50
+ # daily = daily.astype(np.float32)
51
+ # T = daily.shape[0]
52
+ # K = int(math.ceil(T / week_len))
53
+ # out = np.zeros(K, dtype=np.float32)
54
+
55
+ # for w in range(K):
56
+ # e = min((w + 1) * week_len, T)
57
+ # chunk = daily[:e]
58
+ # out[w] = np.nansum(chunk) if agg == "sum" else np.nanmean(chunk)
59
+
60
+ # return out
61
+
62
+ def daily_to_cumulative_weekly(
63
+ daily: np.ndarray,
64
+ agg: str,
65
+ week_len: int = 7,
66
+ ) -> np.ndarray:
67
+ daily = daily.astype(np.float32)
68
+ T = daily.shape[0]
69
+ K = int(math.ceil(T / week_len))
70
+
71
+ weekly = np.zeros(K, dtype=np.float32)
72
+ cumulative = np.zeros(K, dtype=np.float32)
73
+
74
+ for w in range(K):
75
+ s = w * week_len
76
+ e = min((w + 1) * week_len, T)
77
+
78
+ week_chunk = daily[s:e]
79
+ cumulative_chunk = daily[:e]
80
+
81
+ if agg == "sum":
82
+ weekly[w] = np.nansum(week_chunk)
83
+ cumulative[w] = np.nansum(cumulative_chunk)
84
+ else:
85
+ weekly[w] = np.nanmean(week_chunk)
86
+ cumulative[w] = np.nanmean(cumulative_chunk)
87
+
88
+ return np.stack([weekly, cumulative], axis=1).astype(np.float32)
89
+
90
+ def compute_x_stats(dataset, max_samples=20000, seed=0):
91
+ rng = np.random.default_rng(seed)
92
+ n = min(len(dataset), max_samples)
93
+ idxs = rng.choice(len(dataset), size=n, replace=False) if n < len(dataset) else np.arange(len(dataset))
94
+
95
+ weather, soil = [], []
96
+ for i in idxs:
97
+ item = dataset[int(i)]
98
+ weather.append(item["weather"].numpy())
99
+ soil.append(item["soil"].numpy())
100
+
101
+ W_all = np.stack(weather).astype(np.float32)
102
+ S_all = np.stack(soil).astype(np.float32)
103
+
104
+ w_mean = np.nanmean(W_all, axis=(0, 1))
105
+ w_std = np.nanstd(W_all, axis=(0, 1)) + 1e-6
106
+ s_mean = np.nanmean(S_all, axis=0)
107
+ s_std = np.nanstd(S_all, axis=0) + 1e-6
108
+
109
+ w_mean = np.where(np.isfinite(w_mean), w_mean, 0.0)
110
+ w_std = np.where((np.isfinite(w_std)) & (w_std > 1e-6), w_std, 1.0)
111
+ s_mean = np.where(np.isfinite(s_mean), s_mean, 0.0)
112
+ s_std = np.where((np.isfinite(s_std)) & (s_std > 1e-6), s_std, 1.0)
113
+
114
+ return w_mean, w_std, s_mean, s_std
115
+
116
+ def compute_y_stats(dataset):
117
+ ys = np.array([float(dataset[i]["yield"]) for i in range(len(dataset))], dtype=np.float32)
118
+ #ys_log = np.log1p(np.clip(ys, 0.0, None))
119
+ return float(ys.mean()), float(ys.std() + 1e-6)
training_code/examples/sample_input_weekly.json ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "crop": "corn",
3
+ "weather_format": "weekly",
4
+ "cutoff": 52,
5
+ "weather": {
6
+ "prcp": [
7
+ 0.0,
8
+ 0.701298713684082,
9
+ 0.4545454680919647,
10
+ 2.155844211578369,
11
+ 0.9350649118423462,
12
+ 0.11688311398029327,
13
+ 0.0,
14
+ 3.753246784210205,
15
+ 0.15584415197372437,
16
+ 2.2337663173675537,
17
+ 0.0,
18
+ 0.4285714328289032,
19
+ 2.402597427368164,
20
+ 2.3896102905273438,
21
+ 5.753246784210205,
22
+ 0.9220778942108154,
23
+ 0.0,
24
+ 1.1298701763153076,
25
+ 1.2077921628952026,
26
+ 1.2207791805267334,
27
+ 2.441558361053467,
28
+ 5.402597427368164,
29
+ 2.909090995788574,
30
+ 3.6883115768432617,
31
+ 5.1688313484191895,
32
+ 5.233766078948975,
33
+ 7.389610290527344,
34
+ 4.883116722106934,
35
+ 1.5194804668426514,
36
+ 0.8311688303947449,
37
+ 4.051948070526123,
38
+ 0.03896103799343109,
39
+ 0.0,
40
+ 5.350649356842041,
41
+ 2.0389609336853027,
42
+ 4.103896141052246,
43
+ 0.012987012974917889,
44
+ 15.49350643157959,
45
+ 1.1168831586837769,
46
+ 3.142857074737549,
47
+ 6.155844211578369,
48
+ 0.3246753215789795,
49
+ 1.6753246784210205,
50
+ 0.5714285969734192,
51
+ 2.8831169605255127,
52
+ 0.10389610379934311,
53
+ 0.9610389471054077,
54
+ 1.1168831586837769,
55
+ 0.0,
56
+ 0.0,
57
+ 0.051948051899671555,
58
+ 4.181818008422852
59
+ ],
60
+ "srad": [
61
+ 187.0129852294922,
62
+ 181.23635864257812,
63
+ 184.8935089111328,
64
+ 179.7818145751953,
65
+ 255.91688537597656,
66
+ 267.4701232910156,
67
+ 323.158447265625,
68
+ 282.09869384765625,
69
+ 347.76104736328125,
70
+ 274.28570556640625,
71
+ 384.70648193359375,
72
+ 332.7168884277344,
73
+ 384.0831298828125,
74
+ 455.7298583984375,
75
+ 359.5636291503906,
76
+ 525.6727294921875,
77
+ 584.2701416015625,
78
+ 522.7221069335938,
79
+ 385.9532470703125,
80
+ 420.5298767089844,
81
+ 386.36883544921875,
82
+ 335.12725830078125,
83
+ 327.6467590332031,
84
+ 329.35064697265625,
85
+ 319.7090759277344,
86
+ 341.6103820800781,
87
+ 374.150634765625,
88
+ 387.20001220703125,
89
+ 379.0545349121094,
90
+ 412.1766357421875,
91
+ 353.08050537109375,
92
+ 397.0493469238281,
93
+ 376.4363708496094,
94
+ 312.2701416015625,
95
+ 312.4363708496094,
96
+ 313.7662353515625,
97
+ 341.07012939453125,
98
+ 249.10130310058594,
99
+ 292.32208251953125,
100
+ 190.7532501220703,
101
+ 189.13246154785156,
102
+ 301.589599609375,
103
+ 214.69090270996094,
104
+ 198.7324676513672,
105
+ 148.0311737060547,
106
+ 213.77662658691406,
107
+ 168.64414978027344,
108
+ 116.23896026611328,
109
+ 148.4051971435547,
110
+ 169.22596740722656,
111
+ 130.86753845214844,
112
+ 157.7818145751953
113
+ ],
114
+ "swe": [
115
+ 18.909090042114258,
116
+ 18.753246307373047,
117
+ 23.220779418945312,
118
+ 32.93506622314453,
119
+ 37.24675369262695,
120
+ 41.45454406738281,
121
+ 41.45454406738281,
122
+ 51.22077941894531,
123
+ 67.84415435791016,
124
+ 62.181819915771484,
125
+ 61.610389709472656,
126
+ 59.844154357910156,
127
+ 54.80519485473633,
128
+ 71.94805145263672,
129
+ 78.9610366821289,
130
+ 100.98701477050781,
131
+ 84.467529296875,
132
+ 50.54545593261719,
133
+ 7.636363506317139,
134
+ 0.0,
135
+ 0.0,
136
+ 0.0,
137
+ 0.0,
138
+ 0.0,
139
+ 0.0,
140
+ 0.0,
141
+ 0.0,
142
+ 0.0,
143
+ 0.0,
144
+ 0.0,
145
+ 0.0,
146
+ 0.0,
147
+ 0.0,
148
+ 0.0,
149
+ 0.0,
150
+ 0.0,
151
+ 0.0,
152
+ 0.0,
153
+ 0.0,
154
+ 0.0,
155
+ 0.0,
156
+ 0.0,
157
+ 0.0,
158
+ 0.0,
159
+ 0.0,
160
+ 0.0,
161
+ 0.0,
162
+ 1.402597427368164,
163
+ 0.0,
164
+ 0.0,
165
+ 0.0,
166
+ 9.454545021057129
167
+ ],
168
+ "tmax": [
169
+ -13.350648880004883,
170
+ -3.694805145263672,
171
+ -2.4740259647369385,
172
+ -0.1428571492433548,
173
+ -7.298701286315918,
174
+ -10.746753692626953,
175
+ -1.3701298236846924,
176
+ -2.844155788421631,
177
+ 5.285714149475098,
178
+ 0.7272727489471436,
179
+ 4.675324440002441,
180
+ 3.948051929473877,
181
+ 4.324675559997559,
182
+ -0.4285714328289032,
183
+ 3.9285714626312256,
184
+ 8.292207717895508,
185
+ 16.785715103149414,
186
+ 23.915584564208984,
187
+ 19.662338256835938,
188
+ 25.34415626525879,
189
+ 28.39610481262207,
190
+ 28.39610481262207,
191
+ 24.863636016845703,
192
+ 27.415584564208984,
193
+ 25.766233444213867,
194
+ 28.10389518737793,
195
+ 28.441558837890625,
196
+ 30.61688232421875,
197
+ 26.87013053894043,
198
+ 26.402597427368164,
199
+ 26.571428298950195,
200
+ 29.324674606323242,
201
+ 29.714284896850586,
202
+ 25.02597427368164,
203
+ 25.441558837890625,
204
+ 23.467533111572266,
205
+ 28.746753692626953,
206
+ 20.162338256835938,
207
+ 15.733766555786133,
208
+ 12.071428298950195,
209
+ 8.357142448425293,
210
+ 12.409090995788574,
211
+ 11.629870414733887,
212
+ 9.253246307373047,
213
+ -0.012987012974917889,
214
+ -0.6623376607894897,
215
+ 0.9155844449996948,
216
+ -2.0,
217
+ -3.344155788421631,
218
+ 0.5064935088157654,
219
+ 1.7402597665786743,
220
+ -2.0568182468414307
221
+ ],
222
+ "tmin": [
223
+ -23.694805145263672,
224
+ -14.48051929473877,
225
+ -12.051947593688965,
226
+ -7.941558361053467,
227
+ -19.376623153686523,
228
+ -21.123376846313477,
229
+ -14.344156265258789,
230
+ -14.02597427368164,
231
+ -7.7207794189453125,
232
+ -7.7272725105285645,
233
+ -6.344155788421631,
234
+ -4.116883277893066,
235
+ -5.246753215789795,
236
+ -11.681818008422852,
237
+ -3.909090995788574,
238
+ -3.649350643157959,
239
+ 1.4220778942108154,
240
+ 8.98051929473877,
241
+ 8.870129585266113,
242
+ 10.454545021057129,
243
+ 15.129870414733887,
244
+ 16.363636016845703,
245
+ 14.461038589477539,
246
+ 17.175325393676758,
247
+ 16.701297760009766,
248
+ 18.720779418945312,
249
+ 17.922077178955078,
250
+ 19.519479751586914,
251
+ 16.305194854736328,
252
+ 14.642857551574707,
253
+ 15.694805145263672,
254
+ 17.422077178955078,
255
+ 18.220779418945312,
256
+ 15.175324440002441,
257
+ 15.558441162109375,
258
+ 13.49350643157959,
259
+ 17.201297760009766,
260
+ 11.655843734741211,
261
+ 5.7727274894714355,
262
+ 4.551948070526123,
263
+ 2.2272727489471436,
264
+ -0.16233766078948975,
265
+ 1.7727272510528564,
266
+ 1.0324674844741821,
267
+ -5.954545497894287,
268
+ -10.149351119995117,
269
+ -6.623376846313477,
270
+ -7.201298713684082,
271
+ -10.422078132629395,
272
+ -8.344156265258789,
273
+ -4.707792282104492,
274
+ -9.943181991577148
275
+ ],
276
+ "vp": [
277
+ 100.2597427368164,
278
+ 259.22076416015625,
279
+ 287.2727355957031,
280
+ 344.41558837890625,
281
+ 137.14285278320312,
282
+ 115.84415435791016,
283
+ 211.42857360839844,
284
+ 215.06494140625,
285
+ 359.48052978515625,
286
+ 358.4415588378906,
287
+ 377.6623229980469,
288
+ 454.5454406738281,
289
+ 409.8701171875,
290
+ 249.87013244628906,
291
+ 451.4285583496094,
292
+ 438.4415588378906,
293
+ 540.7792358398438,
294
+ 887.792236328125,
295
+ 1041.0389404296875,
296
+ 1108.052001953125,
297
+ 1534.54541015625,
298
+ 1750.6492919921875,
299
+ 1589.6103515625,
300
+ 1931.4285888671875,
301
+ 1902.337646484375,
302
+ 2178.181884765625,
303
+ 2062.857177734375,
304
+ 2275.32470703125,
305
+ 1851.947998046875,
306
+ 1670.1298828125,
307
+ 1802.077880859375,
308
+ 1990.1298828125,
309
+ 2097.662353515625,
310
+ 1738.1817626953125,
311
+ 1810.9090576171875,
312
+ 1587.012939453125,
313
+ 2006.7532958984375,
314
+ 1414.0260009765625,
315
+ 942.337646484375,
316
+ 852.467529296875,
317
+ 731.9480590820312,
318
+ 614.5454711914062,
319
+ 715.8441772460938,
320
+ 658.7012939453125,
321
+ 416.1038818359375,
322
+ 297.1428527832031,
323
+ 392.7272644042969,
324
+ 367.7922058105469,
325
+ 289.35064697265625,
326
+ 336.6233825683594,
327
+ 435.3246765136719,
328
+ 295.4545593261719
329
+ ]
330
+ },
331
+ "soil": {
332
+ "bdod_mean_0-5cm": 127.71450805664062,
333
+ "bdod_mean_5-15cm": 148.86965942382812,
334
+ "bdod_mean_15-30cm": 150.70364379882812,
335
+ "bdod_mean_30-60cm": 158.2148895263672,
336
+ "bdod_mean_60-100cm": 162.8789825439453,
337
+ "bdod_mean_100-200cm": 165.12025451660156,
338
+ "cec_mean_0-5cm": 252.34054565429688,
339
+ "cec_mean_5-15cm": 174.4131317138672,
340
+ "cec_mean_15-30cm": 142.52432250976562,
341
+ "cec_mean_30-60cm": 129.1745147705078,
342
+ "cec_mean_60-100cm": 119.97528839111328,
343
+ "cec_mean_100-200cm": 103.29729461669922,
344
+ "cfvo_mean_0-5cm": 21.431659698486328,
345
+ "cfvo_mean_5-15cm": 22.905019760131836,
346
+ "cfvo_mean_15-30cm": 21.820077896118164,
347
+ "cfvo_mean_30-60cm": 24.725868225097656,
348
+ "cfvo_mean_60-100cm": 34.54826354980469,
349
+ "cfvo_mean_100-200cm": 45.90888214111328,
350
+ "clay_mean_0-5cm": 150.02239990234375,
351
+ "clay_mean_5-15cm": 149.1436309814453,
352
+ "clay_mean_15-30cm": 150.81776428222656,
353
+ "clay_mean_30-60cm": 150.43707275390625,
354
+ "clay_mean_60-100cm": 134.29266357421875,
355
+ "clay_mean_100-200cm": 125.47721862792969,
356
+ "nitrogen_mean_0-5cm": 4723.78857421875,
357
+ "nitrogen_mean_5-15cm": 2148.763671875,
358
+ "nitrogen_mean_15-30cm": 1729.8802490234375,
359
+ "nitrogen_mean_30-60cm": 776.644775390625,
360
+ "nitrogen_mean_60-100cm": 547.14208984375,
361
+ "nitrogen_mean_100-200cm": 534.0015258789062,
362
+ "ocd_mean_0-5cm": 435.4494323730469,
363
+ "ocd_mean_5-15cm": 255.3752899169922,
364
+ "ocd_mean_15-30cm": 181.84786987304688,
365
+ "ocd_mean_30-60cm": 93.57837677001953,
366
+ "ocd_mean_60-100cm": 60.80463409423828,
367
+ "ocd_mean_100-200cm": 44.90888214111328,
368
+ "ocs_mean_0-5cm": 57.79845428466797,
369
+ "ocs_mean_5-15cm": 57.79845428466797,
370
+ "ocs_mean_15-30cm": 57.79845428466797,
371
+ "ocs_mean_30-60cm": 57.79845428466797,
372
+ "ocs_mean_60-100cm": 57.79845428466797,
373
+ "ocs_mean_100-200cm": 57.79845428466797,
374
+ "phh2o_mean_0-5cm": 61.812355041503906,
375
+ "phh2o_mean_5-15cm": 61.73050308227539,
376
+ "phh2o_mean_15-30cm": 61.63166046142578,
377
+ "phh2o_mean_30-60cm": 62.09111785888672,
378
+ "phh2o_mean_60-100cm": 64.0540542602539,
379
+ "phh2o_mean_100-200cm": 69.0687255859375,
380
+ "sand_mean_0-5cm": 625.2216186523438,
381
+ "sand_mean_5-15cm": 626.4702758789062,
382
+ "sand_mean_15-30cm": 631.22314453125,
383
+ "sand_mean_30-60cm": 649.113525390625,
384
+ "sand_mean_60-100cm": 697.4996337890625,
385
+ "sand_mean_100-200cm": 712.781494140625,
386
+ "silt_mean_0-5cm": 224.77297973632812,
387
+ "silt_mean_5-15cm": 224.3876495361328,
388
+ "silt_mean_15-30cm": 217.95135498046875,
389
+ "silt_mean_30-60cm": 200.43319702148438,
390
+ "silt_mean_60-100cm": 168.21775817871094,
391
+ "silt_mean_100-200cm": 161.7498016357422,
392
+ "soc_mean_0-5cm": 446.14508056640625,
393
+ "soc_mean_5-15cm": 223.34988403320312,
394
+ "soc_mean_15-30cm": 144.84561157226562,
395
+ "soc_mean_30-60cm": 107.50271606445312,
396
+ "soc_mean_60-100cm": 53.54926300048828,
397
+ "soc_mean_100-200cm": 47.3972053527832
398
+ }
399
+ }
training_code/hf/__init__.py ADDED
File without changes
training_code/hf/auto.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from transformers import AutoConfig, AutoModel
2
+ from hf.configuration_yield import YieldConfig
3
+ from hf.modeling_yield import YieldForRegression
4
+
5
+
6
+ def register_yield_autoclass():
7
+ try:
8
+ AutoConfig.register("yield-weather-soil", YieldConfig)
9
+ except ValueError:
10
+ pass
11
+
12
+ try:
13
+ AutoModel.register(YieldConfig, YieldForRegression)
14
+ except ValueError:
15
+ pass
training_code/hf/configuration_yield.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from transformers import PretrainedConfig
2
+
3
+
4
+ class YieldConfig(PretrainedConfig):
5
+ model_type = "yield-weather-soil"
6
+
7
+ def __init__(
8
+ self,
9
+ weather_vars=None,
10
+ soil_vars=None,
11
+ w_mean=None,
12
+ w_std=None,
13
+ s_mean=None,
14
+ s_std=None,
15
+ y_mean=None,
16
+ y_std=None,
17
+ K=None,
18
+ W=None,
19
+ S=None,
20
+ train_cutoffs=None,
21
+ eval_cutoffs=None,
22
+ d_model=128,
23
+ nhead=4,
24
+ num_layers=4,
25
+ dim_ff=256,
26
+ dropout=0.3,
27
+ pool="mean",
28
+ use_crop=True,
29
+ crop_emb_dim=8,
30
+ **kwargs,
31
+ ):
32
+ super().__init__(**kwargs)
33
+
34
+ self.weather_vars = weather_vars
35
+ self.soil_vars = soil_vars
36
+
37
+ self.w_mean = w_mean
38
+ self.w_std = w_std
39
+ self.s_mean = s_mean
40
+ self.s_std = s_std
41
+ self.y_mean = y_mean
42
+ self.y_std = y_std
43
+
44
+ self.K = K
45
+ self.W = W
46
+ self.S = S
47
+
48
+ self.train_cutoffs = train_cutoffs
49
+ self.eval_cutoffs = eval_cutoffs
50
+
51
+ self.d_model = d_model
52
+ self.nhead = nhead
53
+ self.num_layers = num_layers
54
+ self.dim_ff = dim_ff
55
+ self.dropout = dropout
56
+ self.pool = pool
57
+ self.use_crop = use_crop
58
+ self.crop_emb_dim = crop_emb_dim
training_code/hf/modeling_yield.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
5
+ sys.path.insert(0, str(PROJECT_ROOT))
6
+
7
+ import torch
8
+ from torch import nn
9
+ from transformers import PreTrainedModel
10
+ from transformers.modeling_outputs import ModelOutput
11
+ from dataclasses import dataclass
12
+
13
+ from hf.configuration_yield import YieldConfig
14
+ from models.unimodal_ws_crossattn import UnimodalWS_CrossAttn_TemporalTF
15
+
16
+
17
+ @dataclass
18
+ class YieldModelOutput(ModelOutput):
19
+ loss: torch.Tensor | None = None
20
+ logits: torch.Tensor | None = None
21
+ predictions: torch.Tensor | None = None
22
+
23
+
24
+ class YieldForRegression(PreTrainedModel):
25
+ config_class = YieldConfig
26
+ base_model_prefix = "yield_model"
27
+
28
+ def __init__(self, config: YieldConfig):
29
+ super().__init__(config)
30
+
31
+ self.yield_model = UnimodalWS_CrossAttn_TemporalTF(
32
+ w_dim=config.W,
33
+ soil_dim=config.S,
34
+ d_model=config.d_model,
35
+ nhead=config.nhead,
36
+ num_layers=config.num_layers,
37
+ dim_ff=config.dim_ff,
38
+ dropout=config.dropout,
39
+ use_crop=config.use_crop,
40
+ crop_emb_dim=config.crop_emb_dim,
41
+ max_weeks=max(32, config.K),
42
+ pool=config.pool,
43
+ )
44
+
45
+ self.post_init()
46
+
47
+ def forward(
48
+ self,
49
+ weather,
50
+ soil,
51
+ crop_id,
52
+ labels=None,
53
+ horizon_idx=None,
54
+ causal=True,
55
+ return_sequence=False,
56
+ return_dict=True,
57
+ ):
58
+ if horizon_idx is None:
59
+ horizon_idx = weather.shape[1]
60
+
61
+ logits = self.yield_model(
62
+ weather,
63
+ soil,
64
+ crop_id,
65
+ horizon_idx=horizon_idx,
66
+ causal=causal,
67
+ return_sequence=return_sequence,
68
+ )
69
+
70
+ y_mean = torch.tensor(self.config.y_mean, device=logits.device, dtype=logits.dtype)
71
+ y_std = torch.tensor(self.config.y_std, device=logits.device, dtype=logits.dtype)
72
+
73
+ #predictions = torch.expm1(logits * y_std + y_mean)
74
+ predictions = logits * y_std + y_mean
75
+
76
+ loss = None
77
+ if labels is not None:
78
+ # labels_log = torch.log1p(torch.clamp(labels, min=0.0))
79
+ # labels_norm = (labels_log - y_mean) / y_std
80
+ # loss = nn.functional.mse_loss(logits, labels_norm)
81
+ labels_norm = (labels - y_mean) / y_std
82
+ loss = nn.functional.mse_loss(logits, labels_norm)
83
+
84
+ if not return_dict:
85
+ return (loss, logits, predictions)
86
+
87
+ return YieldModelOutput(
88
+ loss=loss,
89
+ logits=logits,
90
+ predictions=predictions,
91
+ )
training_code/models/__init__.py ADDED
File without changes
training_code/models/unimodal_ws_crossattn.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+
5
+ class MLP(nn.Module):
6
+ def __init__(self, in_dim: int, out_dim: int, hidden: int = 128, dropout: float = 0.05):
7
+ super().__init__()
8
+ self.net = nn.Sequential(
9
+ nn.Linear(in_dim, hidden//2),
10
+ nn.GELU(),
11
+ nn.Dropout(dropout),
12
+ nn.Linear(hidden//2, hidden),
13
+ nn.GELU(),
14
+ nn.Dropout(dropout),
15
+ nn.Linear(hidden, out_dim)
16
+ )
17
+
18
+ def forward(self, x):
19
+ return self.net(x)
20
+
21
+ class UnimodalWS_CrossAttn_TemporalTF(nn.Module):
22
+ def __init__(
23
+ self,
24
+ w_dim: int,
25
+ soil_dim: int,
26
+ d_model: int = 128,
27
+ nhead: int = 4,
28
+ num_layers: int = 2,
29
+ dim_ff: int = 256,
30
+ dropout: float = 0.05,
31
+ use_crop: bool = True,
32
+ crop_emb_dim: int = 8,
33
+ max_weeks: int = 32,
34
+ pool: str = "mean", # Changed default to mean
35
+ farm_emb_dim: int = 8,
36
+ horizon_emb_dim: int = 16,
37
+ ):
38
+ super().__init__()
39
+ assert pool in ["last", "cls", "mean"]
40
+ self.pool = pool
41
+ self.use_crop = use_crop
42
+ self.max_weeks = max_weeks
43
+
44
+ crop_in = crop_emb_dim if use_crop else 0
45
+ if use_crop:
46
+ self.crop_emb = nn.Embedding(2, crop_emb_dim)
47
+
48
+ self.horizon_emb = nn.Embedding(max_weeks + 1, horizon_emb_dim)
49
+ self.horizon_proj = nn.Linear(horizon_emb_dim, d_model)
50
+
51
+ # Hard Positional Signal: Week ID projection
52
+ self.week_proj = nn.Linear(1, d_model)
53
+
54
+ self.weather_enc = MLP(w_dim + crop_in, d_model, hidden=d_model, dropout=dropout)
55
+ self.soil_enc = MLP(soil_dim + crop_in, d_model, hidden=d_model, dropout=dropout)
56
+
57
+ self.ws_cross_attn = nn.MultiheadAttention(
58
+ embed_dim=d_model,
59
+ num_heads=nhead,
60
+ dropout=dropout,
61
+ batch_first=True,
62
+ )
63
+ self.ws_ln = nn.LayerNorm(d_model)
64
+
65
+ self.pos_emb = nn.Parameter(torch.zeros(1, max_weeks + 1, d_model))
66
+ nn.init.trunc_normal_(self.pos_emb, std=0.02)
67
+
68
+ if pool == "cls":
69
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, d_model))
70
+ nn.init.trunc_normal_(self.cls_token, std=0.02)
71
+
72
+ enc_layer = nn.TransformerEncoderLayer(
73
+ d_model=d_model, nhead=nhead, dim_feedforward=dim_ff,
74
+ dropout=dropout, activation="gelu", batch_first=True, norm_first=True,
75
+ )
76
+ self.temporal_tf = nn.TransformerEncoder(enc_layer, num_layers=num_layers)
77
+
78
+ # Head now takes [Pooled Sequence + Horizon Embedding + Soil Skip Connection]
79
+ self.head = nn.Sequential(
80
+ nn.LayerNorm(d_model + horizon_emb_dim + d_model),
81
+ nn.Linear(d_model + horizon_emb_dim + d_model, d_model),
82
+ nn.GELU(),
83
+ nn.Dropout(dropout),
84
+ nn.Linear(d_model, 1),
85
+ )
86
+
87
+ def forward(
88
+ self,
89
+ weather,
90
+ soil,
91
+ crop_id,
92
+ farm_field_encoded=None,
93
+ week_mask=None,
94
+ causal=False,
95
+ return_sequence=False,
96
+ horizon_idx=None,
97
+ ):
98
+ B, t, W = weather.shape
99
+ device = weather.device
100
+
101
+ # 1. Horizon Processing
102
+ if horizon_idx is None: horizon_idx = torch.tensor(t, device=device).expand(B)
103
+ if not torch.is_tensor(horizon_idx): horizon_idx = torch.tensor(horizon_idx, device=device).expand(B)
104
+
105
+ h_idx = horizon_idx.long().clamp(min=1, max=self.max_weeks)
106
+ h_emb = self.horizon_emb(h_idx)
107
+ h_tok = self.horizon_proj(h_emb) # [B, D]
108
+
109
+ # 2. Input Encoding
110
+ if self.use_crop:
111
+ c = self.crop_emb(crop_id)
112
+ weather_in = torch.cat([weather, c[:, None, :].expand(-1, t, -1)], dim=-1)
113
+ soil_in = torch.cat([soil, c], dim=-1)
114
+ else:
115
+ weather_in, soil_in = weather, soil
116
+
117
+ w_tok = self.weather_enc(weather_in)
118
+ s_encoded = self.soil_enc(soil_in) # [B, D] - Saved for skip connection later
119
+
120
+ # 3. Cross Attention & Horizon
121
+ # attn_out, _ = self.ws_cross_attn(query=w_tok, key=s_encoded[:, None, :], value=s_encoded[:, None, :])
122
+ # fused = self.ws_ln(w_tok + attn_out)
123
+
124
+ fused = self.ws_ln(w_tok + s_encoded[:, None, :])
125
+ fused = fused + h_tok[:, None, :] # Horizon aware tokens
126
+
127
+ # 4. Temporal Transformer
128
+ # Add Hard Week Signal + Learned Pos Emb
129
+ weeks = torch.arange(1, t + 1, device=device).float() / self.max_weeks
130
+ week_signal = self.week_proj(weeks[None, :, None].expand(B, -1, -1))
131
+
132
+ x = fused + week_signal + self.pos_emb[:, :t, :]
133
+
134
+ if self.pool == "cls":
135
+ x = torch.cat([self.cls_token.expand(B, -1, -1), x], dim=1)
136
+ t_idx = t + 1
137
+ else: t_idx = t
138
+
139
+ src_key_padding_mask = ~week_mask.bool() if week_mask is not None else None
140
+ #h = self.temporal_tf(x, src_key_padding_mask=src_key_padding_mask)
141
+
142
+ attn_mask = None
143
+ if causal:
144
+ L = x.size(1)
145
+ attn_mask = torch.triu(
146
+ torch.ones(L, L, device=device, dtype=torch.bool),
147
+ diagonal=1,
148
+ )
149
+
150
+ h = self.temporal_tf(
151
+ x,
152
+ mask=attn_mask,
153
+ src_key_padding_mask=src_key_padding_mask,
154
+ )
155
+
156
+ # 5. Global Mean Pooling
157
+ if self.pool == "mean":
158
+ if week_mask is not None:
159
+ mask = week_mask.unsqueeze(-1).float()
160
+ pooled = (h * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9)
161
+ else:
162
+ pooled = h.mean(dim=1)
163
+ elif self.pool == "cls":
164
+ pooled = h[:, 0, :]
165
+ else: # "last"
166
+ pooled = h[:, -1, :]
167
+
168
+ # 6. Final Fusion (Pooled Sequence + Horizon + Soil Skip)
169
+ out = torch.cat([pooled, h_emb, s_encoded], dim=-1)
170
+ return self.head(out).squeeze(-1)
171
+
training_code/requirements.txt ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ annotated-types==0.7.0
2
+ anyio==4.11.0
3
+ argon2-cffi==25.1.0
4
+ argon2-cffi-bindings==25.1.0
5
+ arrow==1.4.0
6
+ ase==3.22.1
7
+ asttokens==2.2.1
8
+ async-lru==2.0.5
9
+ attrs==25.4.0
10
+ babel==2.17.0
11
+ backcall==0.2.0
12
+ beautifulsoup4==4.14.2
13
+ bleach==6.3.0
14
+ blosc2==4.3.3
15
+ certifi==2026.6.17
16
+ charset-normalizer==3.4.7
17
+ comm==0.2.3
18
+ contourpy==1.0.7
19
+ cycler==0.11.0
20
+ debugpy==1.8.17
21
+ decorator==5.1.1
22
+ defusedxml==0.7.1
23
+ exceptiongroup==1.3.1
24
+ executing==1.2.0
25
+ fastjsonschema==2.21.2
26
+ filelock==3.29.0
27
+ fonttools==4.39.3
28
+ fqdn==1.5.1
29
+ fsspec==2026.4.0
30
+ googledrivedownloader==0.4
31
+ h11==0.16.0
32
+ h5py==3.8.0
33
+ hf-xet==1.5.1
34
+ httpcore==1.0.9
35
+ httpx==0.28.1
36
+ huggingface-hub==0.32.4
37
+ idna==3.18
38
+ ipdb==0.13.7
39
+ ipykernel==7.1.0
40
+ ipython==8.12.0
41
+ ipywidgets==8.1.8
42
+ isodate==0.6.1
43
+ isoduration==20.11.0
44
+ jedi==0.18.2
45
+ Jinja2==3.1.6
46
+ joblib==1.2.0
47
+ json5==0.12.1
48
+ jsonschema==4.25.1
49
+ jsonschema-specifications==2025.9.1
50
+ jupyter==1.1.1
51
+ jupyter-console==6.6.3
52
+ jupyter-events==0.12.0
53
+ jupyter-lsp==2.3.0
54
+ jupyter_client==8.6.3
55
+ jupyter_core==5.9.1
56
+ jupyter_server==2.17.0
57
+ jupyter_server_terminals==0.5.3
58
+ jupyterlab==4.5.0
59
+ jupyterlab_pygments==0.3.0
60
+ jupyterlab_server==2.28.0
61
+ jupyterlab_widgets==3.0.16
62
+ kiwisolver==1.4.4
63
+ lark==1.3.1
64
+ llvmlite==0.39.1
65
+ MarkupSafe==3.0.3
66
+ matplotlib==3.10.7
67
+ matplotlib-inline==0.1.6
68
+ mistune==3.1.4
69
+ mpmath==1.3.0
70
+ msgpack==1.2.1
71
+ nbclient==0.10.2
72
+ nbconvert==7.16.6
73
+ nbformat==5.10.4
74
+ ndindex==1.10.1
75
+ nest-asyncio==1.6.0
76
+ networkx==3.4.2
77
+ notebook==7.5.0
78
+ notebook_shim==0.2.4
79
+ numba==0.56.4
80
+ numexpr==2.14.1
81
+ numpy==2.2.6
82
+ nvidia-cufile-cu12==1.13.1.3
83
+ nvidia-cusparselt-cu12==0.7.1
84
+ nvidia-nvjitlink-cu12==12.8.93
85
+ nvidia-nvshmem-cu12==3.3.20
86
+ overrides==7.7.0
87
+ packaging==26.0
88
+ pandas==2.3.3
89
+ pandocfilters==1.5.1
90
+ parso==0.8.3
91
+ pexpect==4.8.0
92
+ pickleshare==0.7.5
93
+ pillow==12.0.0
94
+ prometheus_client==0.23.1
95
+ prompt-toolkit==3.0.38
96
+ psutil==7.1.3
97
+ ptyprocess==0.7.0
98
+ pure-eval==0.2.2
99
+ py-cpuinfo==9.0.0
100
+ pydantic==2.13.4
101
+ pydantic_core==2.46.4
102
+ Pygments==2.14.0
103
+ pyparsing==3.0.9
104
+ python-dateutil==2.8.2
105
+ python-json-logger==4.0.0
106
+ python-louvain==0.16
107
+ pytz==2023.3
108
+ PyYAML==6.0.3
109
+ pyzmq==27.1.0
110
+ rdflib==6.3.2
111
+ referencing==0.37.0
112
+ regex==2026.6.28
113
+ requests==2.34.2
114
+ rfc3339-validator==0.1.4
115
+ rfc3986-validator==0.1.1
116
+ rfc3987-syntax==1.1.0
117
+ rpds-py==0.29.0
118
+ safetensors==0.8.0
119
+ scikit-learn==1.7.2
120
+ scipy==1.15.3
121
+ seaborn==0.12.2
122
+ Send2Trash==1.8.3
123
+ six==1.16.0
124
+ sklearn==0.0.post1
125
+ sniffio==1.3.1
126
+ soupsieve==2.8
127
+ stack-data==0.6.2
128
+ sympy==1.14.0
129
+ tables==3.10.1
130
+ terminado==0.18.1
131
+ threadpoolctl==3.1.0
132
+ tinycss2==1.4.0
133
+ tokenizers==0.21.1
134
+ toml==0.10.2
135
+ tomli==2.3.0
136
+ torch==2.12.1+cpu
137
+ torch-geometric==1.7.0
138
+ torchcodec==0.8.1
139
+ torchvision==0.27.1+cpu
140
+ tornado==6.5.2
141
+ tqdm==4.68.3
142
+ traitlets==5.9.0
143
+ transformers==4.48.3
144
+ typing-inspection==0.4.2
145
+ typing_extensions==4.15.0
146
+ tzdata==2025.2
147
+ uri-template==1.3.0
148
+ urllib3==2.7.0
149
+ wcwidth==0.2.6
150
+ webcolors==25.10.0
151
+ webencodings==0.5.1
152
+ websocket-client==1.9.0
153
+ widgetsnbextension==4.0.15
training_code/scripts/__init__.py ADDED
File without changes
training_code/scripts/evaluate_hf.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
5
+ sys.path.insert(0, str(PROJECT_ROOT))
6
+
7
+ import argparse
8
+ import json
9
+ import pandas as pd
10
+ import torch
11
+ from torch.utils.data import DataLoader
12
+ from transformers import AutoModel
13
+
14
+ from data.dataset import YieldDataset
15
+ from training.engine import evaluate
16
+ from hf.auto import register_yield_autoclass
17
+
18
+
19
+ def parse_int_list(x):
20
+ return [int(v.strip()) for v in x.split(",") if v.strip()]
21
+
22
+
23
+ def parse_args():
24
+ p = argparse.ArgumentParser()
25
+ p.add_argument("--hf_model_dir", required=True)
26
+ p.add_argument("--test_file", required=True)
27
+ p.add_argument("--cutoffs", default=None)
28
+ p.add_argument("--batch_size", type=int, default=64)
29
+ p.add_argument("--output_csv", default="eval_predictions.csv")
30
+ p.add_argument("--metrics_json", default="eval_metrics.json")
31
+ p.add_argument(
32
+ "--time_agg",
33
+ default="weekly",
34
+ choices=["weekly", "weekly_cumulative"],
35
+ )
36
+ return p.parse_args()
37
+
38
+
39
+ def main():
40
+ args = parse_args()
41
+ register_yield_autoclass()
42
+
43
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
44
+
45
+ model = AutoModel.from_pretrained(args.hf_model_dir).to(device)
46
+ model.eval()
47
+ cfg = model.config
48
+
49
+ cutoffs = parse_int_list(args.cutoffs) if args.cutoffs else cfg.eval_cutoffs
50
+
51
+ test_ds = YieldDataset(
52
+ data_file=args.test_file,
53
+ weather_vars=cfg.weather_vars,
54
+ soil_vars=cfg.soil_vars,
55
+ split="all",
56
+ seed=1234,
57
+ crop=None,
58
+ years=None,
59
+ time_agg=args.time_agg,
60
+ )
61
+ test_ds.set_normalization(
62
+ cfg.w_mean,
63
+ cfg.w_std,
64
+ cfg.s_mean,
65
+ cfg.s_std,
66
+ )
67
+
68
+ loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False)
69
+
70
+ metrics, rows = evaluate(
71
+ model=model,
72
+ loader=loader,
73
+ device=device,
74
+ y_mean=cfg.y_mean,
75
+ y_std=cfg.y_std,
76
+ cutoffs=cutoffs,
77
+ )
78
+
79
+ print(json.dumps(metrics, indent=2))
80
+
81
+ Path(args.output_csv).parent.mkdir(parents=True, exist_ok=True)
82
+ pd.DataFrame(rows).to_csv(args.output_csv, index=False)
83
+
84
+ with open(args.metrics_json, "w") as f:
85
+ json.dump(metrics, f, indent=2)
86
+
87
+ print(f"Saved predictions to {args.output_csv}")
88
+ print(f"Saved metrics to {args.metrics_json}")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
training_code/scripts/inference_hf.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
5
+ sys.path.insert(0, str(PROJECT_ROOT))
6
+
7
+ import argparse
8
+ import json
9
+ import numpy as np
10
+ import pandas as pd
11
+ import torch
12
+ from torch.utils.data import DataLoader
13
+ from transformers import AutoModel
14
+
15
+ from data.dataset import YieldDataset
16
+ from data.preprocessing import daily_to_cumulative_weekly, DEFAULT_WEATHER_AGG_RULES
17
+ from hf.auto import register_yield_autoclass
18
+
19
+
20
+ def parse_args():
21
+ p = argparse.ArgumentParser()
22
+ p.add_argument("--hf_model_dir", required=True)
23
+ p.add_argument("--input_file", default=None)
24
+ p.add_argument("--single_sample_json", default=None)
25
+ p.add_argument("--cutoff", type=int, required=True)
26
+ p.add_argument("--batch_size", type=int, default=64)
27
+ p.add_argument("--output_csv", default="inference_predictions.csv")
28
+ return p.parse_args()
29
+
30
+
31
+ def load_single_sample_json(path, cfg, cutoff):
32
+ with open(path, "r") as f:
33
+ sample = json.load(f)
34
+
35
+ weather_format = sample.get("weather_format", "weekly_cumulative")
36
+
37
+ weather_cols = []
38
+ for v in cfg.weather_vars:
39
+ if v not in sample["weather"]:
40
+ raise ValueError(f"Missing weather variable in JSON: {v}")
41
+
42
+ arr = np.asarray(sample["weather"][v], dtype=np.float32)
43
+
44
+ if weather_format == "daily":
45
+ agg = DEFAULT_WEATHER_AGG_RULES.get(v, "mean")
46
+ arr = daily_to_cumulative_weekly(arr, agg=agg, week_len=7)
47
+ elif weather_format in ("weekly", "weekly_cumulative"):
48
+ pass
49
+ else:
50
+ raise ValueError(
51
+ "weather_format must be 'daily', 'weekly', "
52
+ "or 'weekly_cumulative'."
53
+ )
54
+
55
+ weather_cols.append(arr)
56
+
57
+ lengths = [len(x) for x in weather_cols]
58
+ if len(set(lengths)) != 1:
59
+ raise ValueError(f"Weather variable lengths do not match after aggregation: {lengths}")
60
+
61
+ weather = np.stack(weather_cols, axis=1).astype(np.float32)
62
+
63
+ soil = []
64
+ for v in cfg.soil_vars:
65
+ if v not in sample["soil"]:
66
+ raise ValueError(f"Missing soil variable in JSON: {v}")
67
+ soil.append(float(sample["soil"][v]))
68
+
69
+ soil = np.asarray(soil, dtype=np.float32)
70
+
71
+ w_mean = np.asarray(cfg.w_mean, dtype=np.float32)
72
+ w_std = np.asarray(cfg.w_std, dtype=np.float32)
73
+ s_mean = np.asarray(cfg.s_mean, dtype=np.float32)
74
+ s_std = np.asarray(cfg.s_std, dtype=np.float32)
75
+
76
+ weather = np.where(np.isnan(weather), w_mean[None, :], weather)
77
+ weather = (weather - w_mean[None, :]) / w_std[None, :]
78
+
79
+ soil = np.where(np.isnan(soil), s_mean, soil)
80
+ soil = (soil - s_mean) / s_std
81
+
82
+ crop_map = {"corn": 0, "maize": 0, "soybean": 1, "soy": 1}
83
+ crop = str(sample.get("crop", "corn")).strip().lower()
84
+ crop_id = crop_map.get(crop, 0)
85
+
86
+ t_eff = min(cutoff, weather.shape[0])
87
+
88
+ return {
89
+ "weather": torch.from_numpy(weather[:t_eff]).unsqueeze(0),
90
+ "soil": torch.from_numpy(soil).unsqueeze(0),
91
+ "crop_id": torch.tensor([crop_id], dtype=torch.long),
92
+ "t_eff": t_eff,
93
+ }
94
+
95
+
96
+ @torch.no_grad()
97
+ def main():
98
+ args = parse_args()
99
+ register_yield_autoclass()
100
+
101
+ if args.input_file is None and args.single_sample_json is None:
102
+ raise ValueError("Provide either --input_file or --single_sample_json.")
103
+
104
+ if args.input_file is not None and args.single_sample_json is not None:
105
+ raise ValueError("Use only one: --input_file OR --single_sample_json.")
106
+
107
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
108
+
109
+ model = AutoModel.from_pretrained(args.hf_model_dir).to(device)
110
+ model.eval()
111
+ cfg = model.config
112
+
113
+ if args.single_sample_json is not None:
114
+ sample = load_single_sample_json(args.single_sample_json, cfg, args.cutoff)
115
+
116
+ weather = sample["weather"].to(device)
117
+ soil = sample["soil"].to(device)
118
+ crop_id = sample["crop_id"].to(device)
119
+ t_eff = sample["t_eff"]
120
+
121
+ out = model(
122
+ weather=weather,
123
+ soil=soil,
124
+ crop_id=crop_id,
125
+ horizon_idx=t_eff,
126
+ causal=True,
127
+ return_sequence=False,
128
+ )
129
+
130
+ pred = float(out.predictions.item())
131
+
132
+ Path(args.output_csv).parent.mkdir(parents=True, exist_ok=True)
133
+ pd.DataFrame([{
134
+ "sample_idx": 0,
135
+ "cutoff": int(args.cutoff),
136
+ "y_pred": pred,
137
+ }]).to_csv(args.output_csv, index=False)
138
+
139
+ print(f"Predicted yield: {pred:.4f}")
140
+ print(f"Saved inference prediction to {args.output_csv}")
141
+ return
142
+
143
+ ds = YieldDataset(
144
+ data_file=args.input_file,
145
+ weather_vars=cfg.weather_vars,
146
+ soil_vars=cfg.soil_vars,
147
+ split="all",
148
+ seed=1234,
149
+ crop=None,
150
+ years=None,
151
+ require_yield=False,
152
+ )
153
+
154
+ ds.set_normalization(
155
+ cfg.w_mean,
156
+ cfg.w_std,
157
+ cfg.s_mean,
158
+ cfg.s_std,
159
+ )
160
+
161
+ loader = DataLoader(ds, batch_size=args.batch_size, shuffle=False)
162
+
163
+ rows = []
164
+ sample_idx = 0
165
+
166
+ for batch in loader:
167
+ weather = batch["weather"].to(device)
168
+ soil = batch["soil"].to(device)
169
+ crop_id = batch["crop_id"].to(device)
170
+
171
+ t_eff = min(args.cutoff, weather.size(1))
172
+
173
+ out = model(
174
+ weather=weather[:, :t_eff, :],
175
+ soil=soil,
176
+ crop_id=crop_id,
177
+ horizon_idx=t_eff,
178
+ causal=True,
179
+ return_sequence=False,
180
+ )
181
+
182
+ for pred in out.predictions.detach().cpu().numpy().tolist():
183
+ rows.append({
184
+ "sample_idx": sample_idx,
185
+ "cutoff": int(args.cutoff),
186
+ "y_pred": float(pred),
187
+ })
188
+ sample_idx += 1
189
+
190
+ Path(args.output_csv).parent.mkdir(parents=True, exist_ok=True)
191
+ pd.DataFrame(rows).to_csv(args.output_csv, index=False)
192
+
193
+ print(f"Saved inference predictions to {args.output_csv}")
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
training_code/scripts/prepare_cornbelt.py ADDED
@@ -0,0 +1,710 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+
7
+ # ============================================================
8
+ # Configuration
9
+ # ============================================================
10
+
11
+ INPUT_CSV = Path("khaki_multi_crop_yield.csv")
12
+ OUTPUT_DIR = Path("data/cornbelt")
13
+
14
+ TRAIN_YEARS = [2013, 2014, 2015, 2016]
15
+ VAL_YEAR = 2017
16
+ TEST_YEAR = 2018
17
+
18
+
19
+ # Public dataset weather mapping
20
+ WEATHER_MAP = {
21
+ "prcp": 1,
22
+ "srad": 2,
23
+ "swe": 3,
24
+ "tmax": 4,
25
+ "tmin": 5,
26
+ "vp": 6,
27
+ }
28
+
29
+
30
+ SOIL_MEASUREMENTS = [
31
+ "bdod",
32
+ "cec",
33
+ "cfvo",
34
+ "clay",
35
+ "nitrogen",
36
+ "ocd",
37
+ "ocs",
38
+ "phh2o",
39
+ "sand",
40
+ "silt",
41
+ "soc",
42
+ ]
43
+
44
+
45
+ SOIL_DEPTHS = [
46
+ "0-5cm",
47
+ "5-15cm",
48
+ "15-30cm",
49
+ "30-60cm",
50
+ "60-100cm",
51
+ "100-200cm",
52
+ ]
53
+
54
+
55
+ SOIL_VARS = [
56
+ f"{measurement}_mean_{depth}"
57
+ for measurement in SOIL_MEASUREMENTS
58
+ for depth in SOIL_DEPTHS
59
+ ]
60
+
61
+
62
+ # ============================================================
63
+ # Load
64
+ # ============================================================
65
+
66
+ print(f"Reading {INPUT_CSV}")
67
+
68
+ df = pd.read_csv(INPUT_CSV)
69
+
70
+ print("Raw shape:", df.shape)
71
+
72
+
73
+ # ============================================================
74
+ # Verify required columns
75
+ # ============================================================
76
+
77
+ if "corn_yield" not in df.columns:
78
+ raise ValueError("corn_yield column not found")
79
+
80
+
81
+ required_meta = [
82
+ "loc_ID",
83
+ "year",
84
+ "State",
85
+ "County",
86
+ "lat",
87
+ "lng",
88
+ ]
89
+
90
+ missing_meta = [
91
+ c for c in required_meta
92
+ if c not in df.columns
93
+ ]
94
+
95
+ if missing_meta:
96
+ raise ValueError(
97
+ f"Missing metadata columns: {missing_meta}"
98
+ )
99
+
100
+
101
+ missing_soil = [
102
+ c for c in SOIL_VARS
103
+ if c not in df.columns
104
+ ]
105
+
106
+ if missing_soil:
107
+ raise ValueError(
108
+ f"Missing soil columns: {missing_soil}"
109
+ )
110
+
111
+
112
+ # Verify all 52 weekly values exist for each weather variable
113
+ missing_weather = []
114
+
115
+ for weather_name, source_idx in WEATHER_MAP.items():
116
+ for week in range(1, 53):
117
+ src = f"W_{source_idx}_{week}"
118
+
119
+ if src not in df.columns:
120
+ missing_weather.append(src)
121
+
122
+ if missing_weather:
123
+ raise ValueError(
124
+ f"Missing weather columns: {missing_weather[:20]}"
125
+ )
126
+
127
+
128
+ # ============================================================
129
+ # Convert year and corn yield to numeric
130
+ # ============================================================
131
+
132
+ df["year"] = pd.to_numeric(
133
+ df["year"],
134
+ errors="coerce",
135
+ )
136
+
137
+ df["corn_yield"] = pd.to_numeric(
138
+ df["corn_yield"],
139
+ errors="coerce",
140
+ )
141
+
142
+
143
+ # ============================================================
144
+ # IMPORTANT:
145
+ # Filter to corn samples from 2013-2018 BEFORE constructing out
146
+ # ============================================================
147
+
148
+ df = df[
149
+ (df["year"] >= 2013) &
150
+ (df["year"] <= 2018)
151
+ ].copy()
152
+
153
+
154
+ # Remove samples without valid corn yield or year
155
+ df = df.dropna(
156
+ subset=[
157
+ "corn_yield",
158
+ "year",
159
+ ]
160
+ ).reset_index(drop=True)
161
+
162
+
163
+ df["year"] = df["year"].astype(int)
164
+
165
+
166
+ print()
167
+ print("===================================")
168
+ print("FILTERED SOURCE DATA")
169
+ print("===================================")
170
+
171
+ print(
172
+ "Years retained:",
173
+ sorted(df["year"].unique())
174
+ )
175
+
176
+ print(
177
+ "Rows after 2013-2018 + corn yield filter:",
178
+ len(df)
179
+ )
180
+
181
+ print("\nSamples per year:")
182
+ print(
183
+ df["year"]
184
+ .value_counts()
185
+ .sort_index()
186
+ )
187
+
188
+
189
+ expected_years = {
190
+ 2013,
191
+ 2014,
192
+ 2015,
193
+ 2016,
194
+ 2017,
195
+ 2018,
196
+ }
197
+
198
+ assert set(df["year"].unique()) == expected_years
199
+
200
+
201
+ # ============================================================
202
+ # Construct metadata block
203
+ # ============================================================
204
+
205
+ metadata_df = pd.DataFrame(
206
+ {
207
+ "crop": "corn",
208
+
209
+ # County/location acts as the sample spatial identifier.
210
+ # It is metadata required by the current dataset interface.
211
+ "farm_field": (
212
+ "county_" +
213
+ df["loc_ID"].astype(str)
214
+ ),
215
+
216
+ "year": df["year"].astype(int),
217
+
218
+ "yield": df[
219
+ "corn_yield"
220
+ ].astype(np.float32),
221
+
222
+ "loc_ID": df["loc_ID"],
223
+
224
+ "state": df[
225
+ "State"
226
+ ].astype(str),
227
+
228
+ "county": df[
229
+ "County"
230
+ ].astype(str),
231
+
232
+ "lat": pd.to_numeric(
233
+ df["lat"],
234
+ errors="coerce",
235
+ ).astype(np.float32),
236
+
237
+ "lng": pd.to_numeric(
238
+ df["lng"],
239
+ errors="coerce",
240
+ ).astype(np.float32),
241
+ }
242
+ )
243
+
244
+
245
+ # ============================================================
246
+ # Weather block
247
+ #
248
+ # IMPORTANT:
249
+ # - No interpolation
250
+ # - No daily conversion
251
+ # - No aggregation
252
+ #
253
+ # Each source W_x_1 ... W_x_52 is copied directly.
254
+ #
255
+ # Example:
256
+ # W_1_1 -> prcp_0
257
+ # W_1_2 -> prcp_1
258
+ # ...
259
+ # W_1_52 -> prcp_51
260
+ # ============================================================
261
+
262
+ weather_data = {}
263
+
264
+
265
+ for weather_name, source_idx in WEATHER_MAP.items():
266
+
267
+ for week in range(1, 53):
268
+
269
+ src = f"W_{source_idx}_{week}"
270
+
271
+ # Zero-based temporal indexing used by current loader
272
+ dst = f"{weather_name}_{week - 1}"
273
+
274
+ weather_data[dst] = pd.to_numeric(
275
+ df[src],
276
+ errors="coerce",
277
+ ).astype(np.float32)
278
+
279
+
280
+ weather_df = pd.DataFrame(
281
+ weather_data,
282
+ index=df.index,
283
+ )
284
+
285
+
286
+ # ============================================================
287
+ # Soil block
288
+ # ============================================================
289
+
290
+ soil_data = {}
291
+
292
+
293
+ for col in SOIL_VARS:
294
+
295
+ soil_data[col] = pd.to_numeric(
296
+ df[col],
297
+ errors="coerce",
298
+ ).astype(np.float32)
299
+
300
+
301
+ soil_df = pd.DataFrame(
302
+ soil_data,
303
+ index=df.index,
304
+ )
305
+
306
+
307
+ # ============================================================
308
+ # Combine all blocks
309
+ # ============================================================
310
+
311
+ out = pd.concat(
312
+ [
313
+ metadata_df.reset_index(drop=True),
314
+ weather_df.reset_index(drop=True),
315
+ soil_df.reset_index(drop=True),
316
+ ],
317
+ axis=1,
318
+ )
319
+
320
+
321
+ # Replace inf values with NaN.
322
+ # Normalization / missing-value handling remains in train_hf.py
323
+ # and YieldDataset.
324
+ out = out.replace(
325
+ [np.inf, -np.inf],
326
+ np.nan,
327
+ )
328
+
329
+
330
+ # ============================================================
331
+ # Critical safety check
332
+ # ============================================================
333
+
334
+ print()
335
+ print("===================================")
336
+ print("FINAL MODEL DATASET")
337
+ print("===================================")
338
+
339
+ print(
340
+ "Shape:",
341
+ out.shape,
342
+ )
343
+
344
+ print(
345
+ "Years:",
346
+ sorted(out["year"].unique())
347
+ )
348
+
349
+ print(
350
+ "Rows:",
351
+ len(out)
352
+ )
353
+
354
+
355
+ assert set(out["year"].unique()) == {
356
+ 2013,
357
+ 2014,
358
+ 2015,
359
+ 2016,
360
+ 2017,
361
+ 2018,
362
+ }
363
+
364
+
365
+ assert len(out) == len(df)
366
+
367
+
368
+ # ============================================================
369
+ # Chronological split
370
+ #
371
+ # Train: 2013-2016
372
+ # Val: 2017
373
+ # Test: 2018
374
+ # ============================================================
375
+
376
+ train_df = out[
377
+ out["year"].isin(TRAIN_YEARS)
378
+ ].reset_index(drop=True)
379
+
380
+
381
+ val_df = out[
382
+ out["year"] == VAL_YEAR
383
+ ].reset_index(drop=True)
384
+
385
+
386
+ test_df = out[
387
+ out["year"] == TEST_YEAR
388
+ ].reset_index(drop=True)
389
+
390
+
391
+ # ============================================================
392
+ # Split reporting
393
+ # ============================================================
394
+
395
+ print()
396
+ print("===================================")
397
+ print("SPLITS")
398
+ print("===================================")
399
+
400
+
401
+ print("\nTRAIN")
402
+
403
+ print(
404
+ train_df[
405
+ "year"
406
+ ].value_counts().sort_index()
407
+ )
408
+
409
+ print(
410
+ "Samples:",
411
+ len(train_df),
412
+ )
413
+
414
+ print(
415
+ "Counties:",
416
+ train_df[
417
+ "farm_field"
418
+ ].nunique(),
419
+ )
420
+
421
+
422
+ print("\nVALIDATION")
423
+
424
+ print(
425
+ val_df[
426
+ "year"
427
+ ].value_counts().sort_index()
428
+ )
429
+
430
+ print(
431
+ "Samples:",
432
+ len(val_df),
433
+ )
434
+
435
+ print(
436
+ "Counties:",
437
+ val_df[
438
+ "farm_field"
439
+ ].nunique(),
440
+ )
441
+
442
+
443
+ print("\nTEST")
444
+
445
+ print(
446
+ test_df[
447
+ "year"
448
+ ].value_counts().sort_index()
449
+ )
450
+
451
+ print(
452
+ "Samples:",
453
+ len(test_df),
454
+ )
455
+
456
+ print(
457
+ "Counties:",
458
+ test_df[
459
+ "farm_field"
460
+ ].nunique(),
461
+ )
462
+
463
+
464
+ # ============================================================
465
+ # Split sanity checks
466
+ # ============================================================
467
+
468
+ assert set(
469
+ train_df["year"].unique()
470
+ ) == {
471
+ 2013,
472
+ 2014,
473
+ 2015,
474
+ 2016,
475
+ }
476
+
477
+
478
+ assert set(
479
+ val_df["year"].unique()
480
+ ) == {
481
+ 2017,
482
+ }
483
+
484
+
485
+ assert set(
486
+ test_df["year"].unique()
487
+ ) == {
488
+ 2018,
489
+ }
490
+
491
+
492
+ assert (
493
+ len(train_df)
494
+ + len(val_df)
495
+ + len(test_df)
496
+ == len(out)
497
+ )
498
+
499
+
500
+ # No row should appear in more than one split
501
+ assert set(train_df.index).isdisjoint(
502
+ set(range(
503
+ len(train_df),
504
+ len(train_df) + len(val_df)
505
+ ))
506
+ )
507
+
508
+
509
+ print()
510
+ print("All year/split checks passed.")
511
+
512
+
513
+ # ============================================================
514
+ # Weather sanity checks
515
+ # ============================================================
516
+
517
+ weather_vars = list(
518
+ WEATHER_MAP.keys()
519
+ )
520
+
521
+
522
+ for weather_var in weather_vars:
523
+
524
+ cols = [
525
+ c
526
+ for c in out.columns
527
+ if c.startswith(
528
+ f"{weather_var}_"
529
+ )
530
+ ]
531
+
532
+ assert len(cols) == 52, (
533
+ f"{weather_var}: expected 52 weekly "
534
+ f"columns, found {len(cols)}"
535
+ )
536
+
537
+
538
+ print()
539
+ print("Weather variables:")
540
+ print(weather_vars)
541
+
542
+ print(
543
+ "Weeks per weather variable:",
544
+ 52,
545
+ )
546
+
547
+ print(
548
+ "Total weather columns:",
549
+ 52 * len(weather_vars),
550
+ )
551
+
552
+
553
+ # ============================================================
554
+ # Soil sanity checks
555
+ # ============================================================
556
+
557
+ assert len(SOIL_VARS) == 66
558
+
559
+ assert all(
560
+ col in out.columns
561
+ for col in SOIL_VARS
562
+ )
563
+
564
+
565
+ print(
566
+ "Number of soil variables:",
567
+ len(SOIL_VARS),
568
+ )
569
+
570
+
571
+ # ============================================================
572
+ # Check expected tensor dimensions
573
+ # ============================================================
574
+
575
+ print()
576
+ print("Expected model input dimensions:")
577
+
578
+ print(
579
+ "weather = [52, 6]"
580
+ )
581
+
582
+ print(
583
+ "soil = [66]"
584
+ )
585
+
586
+
587
+ # ============================================================
588
+ # Yield summaries
589
+ # ============================================================
590
+
591
+ print()
592
+ print("===================================")
593
+ print("YIELD SUMMARY")
594
+ print("===================================")
595
+
596
+
597
+ print("\nTrain:")
598
+ print(
599
+ train_df[
600
+ "yield"
601
+ ].describe()
602
+ )
603
+
604
+
605
+ print("\nValidation:")
606
+ print(
607
+ val_df[
608
+ "yield"
609
+ ].describe()
610
+ )
611
+
612
+
613
+ print("\nTest:")
614
+ print(
615
+ test_df[
616
+ "yield"
617
+ ].describe()
618
+ )
619
+
620
+
621
+ # ============================================================
622
+ # Save HDF5 files
623
+ # ============================================================
624
+
625
+ OUTPUT_DIR.mkdir(
626
+ parents=True,
627
+ exist_ok=True,
628
+ )
629
+
630
+
631
+ train_path = OUTPUT_DIR / "train.h5"
632
+ val_path = OUTPUT_DIR / "val.h5"
633
+ test_path = OUTPUT_DIR / "test.h5"
634
+
635
+
636
+ train_df.to_hdf(
637
+ train_path,
638
+ key="data",
639
+ mode="w",
640
+ )
641
+
642
+
643
+ val_df.to_hdf(
644
+ val_path,
645
+ key="data",
646
+ mode="w",
647
+ )
648
+
649
+
650
+ test_df.to_hdf(
651
+ test_path,
652
+ key="data",
653
+ mode="w",
654
+ )
655
+
656
+
657
+ print()
658
+ print("===================================")
659
+ print("SAVED")
660
+ print("===================================")
661
+
662
+ print(train_path)
663
+ print(val_path)
664
+ print(test_path)
665
+
666
+
667
+ # ============================================================
668
+ # Reload files to verify they were written correctly
669
+ # ============================================================
670
+
671
+ train_check = pd.read_hdf(train_path)
672
+ val_check = pd.read_hdf(val_path)
673
+ test_check = pd.read_hdf(test_path)
674
+
675
+
676
+ assert len(train_check) == len(train_df)
677
+ assert len(val_check) == len(val_df)
678
+ assert len(test_check) == len(test_df)
679
+
680
+
681
+ assert set(
682
+ train_check["year"].unique()
683
+ ) == {
684
+ 2013,
685
+ 2014,
686
+ 2015,
687
+ 2016,
688
+ }
689
+
690
+
691
+ assert set(
692
+ val_check["year"].unique()
693
+ ) == {
694
+ 2017,
695
+ }
696
+
697
+
698
+ assert set(
699
+ test_check["year"].unique()
700
+ ) == {
701
+ 2018,
702
+ }
703
+
704
+
705
+ print()
706
+ print("HDF5 reload verification passed.")
707
+
708
+ print()
709
+ print("Done.")
710
+
training_code/scripts/train_hf.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
5
+ sys.path.insert(0, str(PROJECT_ROOT))
6
+
7
+ from tqdm.auto import tqdm
8
+ import time
9
+ import argparse
10
+ import json
11
+ import random
12
+ import pandas as pd
13
+ import torch
14
+ from torch.utils.data import DataLoader
15
+ from transformers import AutoConfig, AutoModel
16
+
17
+ from config.config import TrainConfig
18
+ from data.dataset import YieldDataset
19
+ from data.preprocessing import compute_x_stats, compute_y_stats
20
+ from training.engine import evaluate
21
+ from hf.configuration_yield import YieldConfig
22
+ from hf.auto import register_yield_autoclass
23
+
24
+
25
+ def parse_list(x):
26
+ return [v.strip() for v in x.split(",") if v.strip()]
27
+
28
+
29
+ def parse_int_list(x):
30
+ return [int(v.strip()) for v in x.split(",") if v.strip()]
31
+
32
+
33
+ def parse_args():
34
+ p = argparse.ArgumentParser()
35
+ p.add_argument("--train_file", required=True)
36
+ p.add_argument("--val_file", default=None)
37
+ p.add_argument("--test_file", default=None)
38
+ p.add_argument("--weather_vars", required=True)
39
+ p.add_argument("--soil_vars", required=True)
40
+ p.add_argument("--crop", default=None)
41
+ p.add_argument("--years", default=None)
42
+ p.add_argument("--train_cutoffs", default="4,8,12,16,22")
43
+ p.add_argument("--eval_cutoffs", default=None)
44
+ p.add_argument("--out_dir", default="outputs/yield_hf_model")
45
+ p.add_argument("--epochs", type=int, default=10)
46
+ p.add_argument("--batch_size", type=int, default=64)
47
+ p.add_argument("--lr", type=float, default=3e-4)
48
+ p.add_argument("--weight_decay", type=float, default=1e-4)
49
+ p.add_argument("--seed", type=int, default=1234)
50
+ p.add_argument(
51
+ "--time_agg",
52
+ default="weekly_cumulative",
53
+ choices=["weekly", "weekly_cumulative"],
54
+ )
55
+ return p.parse_args()
56
+
57
+
58
+ def train_one_epoch_hf(model, loader, optimizer, device, cutoffs):
59
+ model.train()
60
+
61
+ total_loss = 0.0
62
+ n_total = 0
63
+ se_raw = 0.0
64
+ n = 0
65
+
66
+ pbar = tqdm(
67
+ loader,
68
+ desc="Training",
69
+ leave=False,
70
+ )
71
+
72
+ for batch in pbar:
73
+ weather = batch["weather"].to(device)
74
+ soil = batch["soil"].to(device)
75
+ crop_id = batch["crop_id"].to(device)
76
+ labels = batch["yield"].to(device)
77
+
78
+ # t = random.choice(cutoffs)
79
+ # t_eff = min(t, weather.size(1))
80
+
81
+ # out = model(
82
+ # weather=weather[:, :t_eff, :],
83
+ # soil=soil,
84
+ # crop_id=crop_id,
85
+ # labels=labels,
86
+ # horizon_idx=t_eff,
87
+ # causal=True,
88
+ # return_sequence=False,
89
+ # )
90
+
91
+ # loss = out.loss
92
+
93
+ losses = []
94
+ preds_for_rmse = []
95
+
96
+ for t in cutoffs:
97
+ t_eff = min(t, weather.size(1))
98
+
99
+ out = model(
100
+ weather=weather[:, :t_eff, :],
101
+ soil=soil,
102
+ crop_id=crop_id,
103
+ labels=labels,
104
+ horizon_idx=t_eff,
105
+ causal=True,
106
+ return_sequence=False,
107
+ )
108
+
109
+ losses.append(out.loss)
110
+ preds_for_rmse.append(out.predictions)
111
+
112
+ loss = torch.stack(losses).mean()
113
+
114
+ optimizer.zero_grad(set_to_none=True)
115
+ loss.backward()
116
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
117
+ optimizer.step()
118
+
119
+ total_loss += loss.item() * labels.size(0)
120
+ n_total += labels.size(0)
121
+
122
+ with torch.no_grad():
123
+ # pred = out.predictions
124
+ # se_raw += ((pred - labels) ** 2).sum().item()
125
+ # n += labels.numel()
126
+ pred = torch.stack(preds_for_rmse, dim=0).mean(dim=0)
127
+ se_raw += ((pred - labels) ** 2).sum().item()
128
+ n += labels.numel()
129
+
130
+ rmse = (se_raw / max(n, 1)) ** 0.5
131
+
132
+ return {
133
+ "loss": total_loss / max(n_total, 1),
134
+ "rmse": rmse,
135
+ }
136
+
137
+
138
+ def save_json(path, obj):
139
+ path = Path(path)
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+ with open(path, "w") as f:
142
+ json.dump(obj, f, indent=2, default=str)
143
+
144
+
145
+ def main():
146
+ print("Starting training script...")
147
+ args = parse_args()
148
+ register_yield_autoclass()
149
+
150
+ cfg = TrainConfig(
151
+ mode="train_eval",
152
+ train_file=Path(args.train_file),
153
+ val_file=Path(args.val_file) if args.val_file else None,
154
+ test_file=Path(args.test_file) if args.test_file else None,
155
+ weather_vars=parse_list(args.weather_vars),
156
+ soil_vars=parse_list(args.soil_vars),
157
+ crop=args.crop,
158
+ years=args.years,
159
+ train_cutoffs=parse_int_list(args.train_cutoffs),
160
+ eval_cutoffs=parse_int_list(args.eval_cutoffs) if args.eval_cutoffs else parse_int_list(args.train_cutoffs),
161
+ out_dir=Path(args.out_dir),
162
+ epochs=args.epochs,
163
+ batch_size=args.batch_size,
164
+ lr=args.lr,
165
+ weight_decay=args.weight_decay,
166
+ seed=args.seed,
167
+ )
168
+
169
+ cfg.validate()
170
+ cfg.save()
171
+
172
+ torch.manual_seed(cfg.seed)
173
+ random.seed(cfg.seed)
174
+
175
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
176
+
177
+ train_ds = YieldDataset(
178
+ data_file=cfg.train_file,
179
+ weather_vars=cfg.weather_vars,
180
+ soil_vars=cfg.soil_vars,
181
+ split="train" if cfg.val_file is None else "all",
182
+ seed=cfg.seed,
183
+ crop=cfg.crop,
184
+ years=cfg.years,
185
+ split_strategy=cfg.split_strategy,
186
+ val_split=cfg.val_split,
187
+ test_split=cfg.test_split,
188
+ time_agg=args.time_agg,
189
+ )
190
+
191
+ if cfg.val_file:
192
+ val_ds = YieldDataset(
193
+ data_file=cfg.val_file,
194
+ weather_vars=cfg.weather_vars,
195
+ soil_vars=cfg.soil_vars,
196
+ split="all",
197
+ seed=cfg.seed,
198
+ crop=cfg.crop,
199
+ years=cfg.years,
200
+ time_agg=args.time_agg,
201
+ )
202
+ else:
203
+ val_ds = YieldDataset(
204
+ data_file=cfg.train_file,
205
+ weather_vars=cfg.weather_vars,
206
+ soil_vars=cfg.soil_vars,
207
+ split="val",
208
+ seed=cfg.seed,
209
+ crop=cfg.crop,
210
+ years=cfg.years,
211
+ split_strategy=cfg.split_strategy,
212
+ val_split=cfg.val_split,
213
+ test_split=cfg.test_split,
214
+ time_agg=args.time_agg,
215
+ )
216
+
217
+ w_mean, w_std, s_mean, s_std = compute_x_stats(train_ds, seed=cfg.seed)
218
+ y_mean, y_std = compute_y_stats(train_ds)
219
+
220
+ train_ds.set_normalization(w_mean, w_std, s_mean, s_std)
221
+ val_ds.set_normalization(w_mean, w_std, s_mean, s_std)
222
+
223
+ b0 = train_ds[0]
224
+ K, W = b0["weather"].shape
225
+ S = b0["soil"].shape[0]
226
+
227
+ hf_config = YieldConfig(
228
+ weather_vars=cfg.weather_vars,
229
+ soil_vars=cfg.soil_vars,
230
+ w_mean=w_mean.tolist(),
231
+ w_std=w_std.tolist(),
232
+ s_mean=s_mean.tolist(),
233
+ s_std=s_std.tolist(),
234
+ y_mean=float(y_mean),
235
+ y_std=float(y_std),
236
+ K=int(K),
237
+ W=int(W),
238
+ S=int(S),
239
+ train_cutoffs=cfg.train_cutoffs,
240
+ eval_cutoffs=cfg.eval_cutoffs,
241
+ d_model=cfg.d_model,
242
+ nhead=cfg.nhead,
243
+ num_layers=cfg.num_layers,
244
+ dim_ff=cfg.dim_ff,
245
+ dropout=cfg.dropout,
246
+ pool=cfg.pool,
247
+ use_crop=cfg.use_crop,
248
+ crop_emb_dim=cfg.crop_emb_dim,
249
+ )
250
+
251
+ model = AutoModel.from_config(hf_config).to(device)
252
+
253
+ optimizer = torch.optim.AdamW(
254
+ model.parameters(),
255
+ lr=cfg.lr,
256
+ weight_decay=cfg.weight_decay,
257
+ )
258
+
259
+ train_loader = DataLoader(train_ds, batch_size=cfg.batch_size, shuffle=True, num_workers=0)
260
+ val_loader = DataLoader(val_ds, batch_size=cfg.batch_size, shuffle=False, num_workers=0)
261
+
262
+ best_val = float("inf")
263
+ best_metrics = None
264
+ patience = 5
265
+ bad_epochs = 0
266
+
267
+ for epoch in range(1, cfg.epochs + 1):
268
+ epoch_start = time.time()
269
+
270
+ train_metrics = train_one_epoch_hf(
271
+ model=model,
272
+ loader=train_loader,
273
+ optimizer=optimizer,
274
+ device=device,
275
+ cutoffs=cfg.train_cutoffs,
276
+ )
277
+
278
+ val_metrics_by_t, _ = evaluate(
279
+ model=model,
280
+ loader=val_loader,
281
+ device=device,
282
+ y_mean=y_mean,
283
+ y_std=y_std,
284
+ cutoffs=cfg.eval_cutoffs,
285
+ )
286
+
287
+ epoch_time = time.time() - epoch_start
288
+ remaining = (cfg.epochs - epoch) * epoch_time
289
+
290
+ print(
291
+ f"\nEpoch {epoch}/{cfg.epochs}"
292
+ f" | time={epoch_time/60:.1f} min"
293
+ f" | ETA={remaining/60:.1f} min"
294
+ )
295
+ val_rmse = sum(m["rmse"] for m in val_metrics_by_t.values()) / len(val_metrics_by_t)
296
+
297
+ print(
298
+ f"Epoch {epoch:03d} | "
299
+ f"train_rmse={train_metrics['rmse']:.4f} | "
300
+ f"val_rmse={val_rmse:.4f}"
301
+ )
302
+
303
+ print("\nValidation Metrics")
304
+ print("-" * 42)
305
+ print(f"{'Cutoff':>8} {'RMSE':>10} {'Bias':>10} {'R²':>10}")
306
+
307
+ for cutoff in cfg.eval_cutoffs:
308
+ m = val_metrics_by_t[cutoff]
309
+ print(
310
+ f"{cutoff:>8}"
311
+ f"{m['rmse']:>10.3f}"
312
+ f"{m['bias']:>10.3f}"
313
+ f"{m['r2']:>10.3f}"
314
+ )
315
+
316
+ if val_rmse < best_val:
317
+ bad_epochs = 0
318
+ best_val = val_rmse
319
+ best_metrics = {
320
+ "epoch": epoch,
321
+ "val_rmse": val_rmse,
322
+ "val_by_cutoff": val_metrics_by_t,
323
+ }
324
+
325
+ model.save_pretrained(cfg.out_dir)
326
+ hf_config.save_pretrained(cfg.out_dir)
327
+ save_json(cfg.out_dir / "metrics.json", best_metrics)
328
+
329
+ print(f"Saved best HF model to {cfg.out_dir}")
330
+ else:
331
+ bad_epochs += 1
332
+ print(f"No improvement for {bad_epochs}/{patience} epochs.")
333
+
334
+ if bad_epochs >= patience:
335
+ print("Early stopping.")
336
+ break
337
+
338
+ print("Best metrics:")
339
+ print(json.dumps(best_metrics, indent=2))
340
+
341
+ if cfg.test_file is not None:
342
+ print("\nRunning final test using best HF model...")
343
+
344
+ best_model = AutoModel.from_pretrained(cfg.out_dir).to(device)
345
+ best_model.eval()
346
+
347
+ test_ds = YieldDataset(
348
+ data_file=cfg.test_file,
349
+ weather_vars=cfg.weather_vars,
350
+ soil_vars=cfg.soil_vars,
351
+ split="all",
352
+ seed=cfg.seed,
353
+ crop=cfg.crop,
354
+ years=cfg.years,
355
+ time_agg=args.time_agg,
356
+ )
357
+
358
+ test_ds.set_normalization(w_mean, w_std, s_mean, s_std)
359
+
360
+ test_loader = DataLoader(test_ds, batch_size=cfg.batch_size, shuffle=False, num_workers=0)
361
+
362
+ test_metrics, test_rows = evaluate(
363
+ model=best_model,
364
+ loader=test_loader,
365
+ device=device,
366
+ y_mean=y_mean,
367
+ y_std=y_std,
368
+ cutoffs=cfg.eval_cutoffs,
369
+ )
370
+
371
+ print("Final test metrics:")
372
+ print(json.dumps(test_metrics, indent=2))
373
+
374
+ save_json(cfg.out_dir / "test_metrics.json", test_metrics)
375
+ pd.DataFrame(test_rows).to_csv(cfg.out_dir / "test_predictions.csv", index=False)
376
+
377
+ print(f"Saved final test outputs to {cfg.out_dir}")
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()
training_code/training.slurm ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ #SBATCH --job-name=yield-cornbelt
3
+ #SBATCH --mail-type=ALL
4
+ #SBATCH --mail-user=sridhar.86@buckeyemail.osu.edu
5
+ #SBATCH --time=08:00:00
6
+ #SBATCH --account=PAS2699
7
+ #SBATCH --output=yield-training.%j.out
8
+ #SBATCH --mem=100G
9
+ #SBATCH --gpus-per-node=1
10
+
11
+ module load python/3.12
12
+
13
+ source activate yield_hf
14
+
15
+ python scripts/train_hf.py \
16
+ --train_file data/cornbelt/train.h5 \
17
+ --val_file data/cornbelt/val.h5 \
18
+ --test_file data/cornbelt/test.h5 \
19
+ --weather_vars prcp,srad,swe,tmax,tmin,vp \
20
+ --soil_vars bdod_mean_0-5cm,bdod_mean_5-15cm,bdod_mean_15-30cm,bdod_mean_30-60cm,bdod_mean_60-100cm,bdod_mean_100-200cm,cec_mean_0-5cm,cec_mean_5-15cm,cec_mean_15-30cm,cec_mean_30-60cm,cec_mean_60-100cm,cec_mean_100-200cm,cfvo_mean_0-5cm,cfvo_mean_5-15cm,cfvo_mean_15-30cm,cfvo_mean_30-60cm,cfvo_mean_60-100cm,cfvo_mean_100-200cm,clay_mean_0-5cm,clay_mean_5-15cm,clay_mean_15-30cm,clay_mean_30-60cm,clay_mean_60-100cm,clay_mean_100-200cm,nitrogen_mean_0-5cm,nitrogen_mean_5-15cm,nitrogen_mean_15-30cm,nitrogen_mean_30-60cm,nitrogen_mean_60-100cm,nitrogen_mean_100-200cm,ocd_mean_0-5cm,ocd_mean_5-15cm,ocd_mean_15-30cm,ocd_mean_30-60cm,ocd_mean_60-100cm,ocd_mean_100-200cm,ocs_mean_0-5cm,ocs_mean_5-15cm,ocs_mean_15-30cm,ocs_mean_30-60cm,ocs_mean_60-100cm,ocs_mean_100-200cm,phh2o_mean_0-5cm,phh2o_mean_5-15cm,phh2o_mean_15-30cm,phh2o_mean_30-60cm,phh2o_mean_60-100cm,phh2o_mean_100-200cm,sand_mean_0-5cm,sand_mean_5-15cm,sand_mean_15-30cm,sand_mean_30-60cm,sand_mean_60-100cm,sand_mean_100-200cm,silt_mean_0-5cm,silt_mean_5-15cm,silt_mean_15-30cm,silt_mean_30-60cm,silt_mean_60-100cm,silt_mean_100-200cm,soc_mean_0-5cm,soc_mean_5-15cm,soc_mean_15-30cm,soc_mean_30-60cm,soc_mean_60-100cm,soc_mean_100-200cm \
21
+ --crop corn \
22
+ --time_agg weekly \
23
+ --train_cutoffs 20,24,28,32,36,40,44,48,52 \
24
+ --eval_cutoffs 20,24,28,32,36,40,44,48,52 \
25
+ --epochs 30 \
26
+ --lr 3e-5 \
27
+ --batch_size 32 \
28
+ --out_dir checkpoints
training_code/training/__init__.py ADDED
File without changes
training_code/training/engine.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import random
3
+ import torch
4
+ import torch.nn.functional as F
5
+ import numpy as np
6
+ from sklearn.metrics import r2_score
7
+ from tqdm.auto import tqdm
8
+
9
+ def train_one_epoch(model, loader, optimizer, device, y_mean, y_std, cutoffs):
10
+ model.train()
11
+ total_loss = 0.0
12
+ n_total = 0
13
+
14
+ se_norm = 0.0
15
+ se_raw = 0.0
16
+ n = 0
17
+
18
+ for batch in loader:
19
+ weather = batch["weather"].to(device)
20
+ soil = batch["soil"].to(device)
21
+ crop_id = batch["crop_id"].to(device)
22
+ y = batch["yield"].to(device)
23
+
24
+ t = random.choice(cutoffs)
25
+ t_eff = min(t, weather.size(1))
26
+
27
+ y_log = torch.log1p(torch.clamp(y, min=0.0))
28
+ y_norm = (y_log - y_mean) / y_std
29
+
30
+ yhat_norm = model(
31
+ weather[:, :t_eff, :],
32
+ soil,
33
+ crop_id,
34
+ horizon_idx=t_eff,
35
+ causal=True,
36
+ return_sequence=False,
37
+ )
38
+
39
+ loss = F.mse_loss(yhat_norm, y_norm)
40
+
41
+ optimizer.zero_grad(set_to_none=True)
42
+ loss.backward()
43
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
44
+ optimizer.step()
45
+
46
+ total_loss += loss.item() * y.size(0)
47
+ n_total += y.size(0)
48
+
49
+ with torch.no_grad():
50
+ yhat = torch.expm1(yhat_norm * y_std + y_mean)
51
+ se_norm += ((yhat_norm - y_norm) ** 2).sum().item()
52
+ se_raw += ((yhat - y) ** 2).sum().item()
53
+ n += y.numel()
54
+
55
+ return {
56
+ "loss_norm_mse": total_loss / max(n_total, 1),
57
+ "norm_rmse": math.sqrt(se_norm / max(n, 1)),
58
+ "rmse": math.sqrt(se_raw / max(n, 1)),
59
+ }
60
+
61
+ @torch.no_grad()
62
+ def evaluate(model, loader, device, y_mean, y_std, cutoffs):
63
+ model.eval()
64
+ results = {}
65
+ all_rows = []
66
+
67
+ for t in sorted(cutoffs):
68
+ y_true_all = []
69
+ y_pred_all = []
70
+
71
+ pbar = tqdm(
72
+ loader,
73
+ desc="Validation",
74
+ leave=False,
75
+ )
76
+
77
+ for batch in pbar:
78
+ weather = batch["weather"].to(device)
79
+ soil = batch["soil"].to(device)
80
+ crop_id = batch["crop_id"].to(device)
81
+ y = batch["yield"].to(device)
82
+
83
+ t_eff = min(t, weather.size(1))
84
+
85
+ out = model(
86
+ weather=weather[:, :t_eff, :],
87
+ soil=soil,
88
+ crop_id=crop_id,
89
+ horizon_idx=t_eff,
90
+ causal=True,
91
+ return_sequence=False,
92
+ )
93
+
94
+ if hasattr(out, "predictions"):
95
+ yhat = out.predictions
96
+ else:
97
+ #yhat = torch.expm1(out * y_std + y_mean)
98
+ yhat = out * y_std + y_mean
99
+
100
+ y_true_all.extend(y.detach().cpu().numpy().tolist())
101
+ y_pred_all.extend(yhat.detach().cpu().numpy().tolist())
102
+
103
+ y_true = np.asarray(y_true_all, dtype=np.float32)
104
+ y_pred = np.asarray(y_pred_all, dtype=np.float32)
105
+ err = y_pred - y_true
106
+
107
+ results[int(t)] = {
108
+ "rmse": float(np.sqrt(np.mean(err ** 2))),
109
+ "bias": float(np.mean(err)),
110
+ "r2": float(r2_score(y_true, y_pred)) if len(y_true) > 1 else float("nan"),
111
+ }
112
+
113
+ for i, (yt, yp) in enumerate(zip(y_true, y_pred)):
114
+ all_rows.append({
115
+ "cutoff": int(t),
116
+ "sample_idx": i,
117
+ "y_true": float(yt),
118
+ "y_pred": float(yp),
119
+ "error": float(yp - yt),
120
+ })
121
+
122
+ return results, all_rows