Sarikaa-Sridhar commited on
Commit
fb4abf6
·
verified ·
1 Parent(s): c6a5177

Upload 10 files

Browse files
README.md ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ````markdown
2
+ ---
3
+ library_name: transformers
4
+
5
+ tags:
6
+ - transformers
7
+ - agriculture
8
+ - crop-yield
9
+ - yield-prediction
10
+ - weather
11
+ - soil
12
+ - regression
13
+ - time-series
14
+ ---
15
+
16
+ # Yield Estimation Transformer
17
+
18
+ A Hugging Face Transformers model for crop yield prediction using weather time-series and soil properties.
19
+
20
+ This repository contains a pretrained transformer model packaged for inference through the Hugging Face Transformers API and a custom Hugging Face Pipeline. The pipeline automatically performs preprocessing, feature normalization, model inference, and returns the predicted crop yield from daily weather observations and static soil properties.
21
+
22
+ ---
23
+
24
+ # Features
25
+
26
+ - Transformer-based crop yield prediction
27
+ - Weather time-series and static soil feature integration
28
+ - Automatic preprocessing and normalization
29
+ - Daily weather input support
30
+ - Hugging Face AutoClass compatible
31
+ - Custom Hugging Face Pipeline
32
+ - CPU and GPU inference
33
+
34
+ ---
35
+
36
+ # Quick Start
37
+
38
+ ```python
39
+ import json
40
+ from transformers import pipeline
41
+
42
+ pipe = pipeline(
43
+ "yield-estimation",
44
+ model="Sarikaa-Sridhar/yield-estimation-transformer",
45
+ trust_remote_code=True,
46
+ )
47
+
48
+ with open("sample_input_daily.json") as f:
49
+ sample = json.load(f)
50
+
51
+ prediction = pipe(sample)
52
+
53
+ print(prediction)
54
+ ````
55
+
56
+ Example output
57
+
58
+ ```json
59
+ {
60
+ "predicted_yield": 182.28,
61
+ "cutoff": 4,
62
+ "effective_cutoff": 4,
63
+ "crop": "corn",
64
+ "weather_format": "daily"
65
+ }
66
+ ```
67
+
68
+ ---
69
+
70
+ # Installation
71
+
72
+ Clone the repository.
73
+
74
+ ```bash
75
+ git clone https://huggingface.co/Sarikaa-Sridhar/yield-estimation-transformer
76
+ cd yield-estimation-transformer
77
+ ```
78
+
79
+ Create a Python environment.
80
+
81
+ ```bash
82
+ conda create -n yield_hf python=3.10
83
+ conda activate yield_hf
84
+ ```
85
+
86
+ Install the required dependencies.
87
+
88
+ ```bash
89
+ pip install -r requirements.txt
90
+ ```
91
+
92
+ ---
93
+
94
+ # Repository Structure
95
+
96
+ ```text
97
+ .
98
+ ├── config.json
99
+ ├── model.safetensors
100
+ ├── configuration_yield.py
101
+ ├── modeling_yield.py
102
+ ├── pipeline_yield.py
103
+ ├── yield_transformer.py
104
+ ├── sample_input_daily.json
105
+ ├── requirements.txt
106
+ └── README.md
107
+ ```
108
+
109
+ ---
110
+
111
+ # Loading the Model
112
+
113
+ The model can be loaded directly using the Hugging Face AutoModel interface.
114
+
115
+ ```python
116
+ from transformers import AutoModel
117
+
118
+ model = AutoModel.from_pretrained(
119
+ "Sarikaa-Sridhar/yield-estimation-transformer",
120
+ trust_remote_code=True,
121
+ )
122
+ ```
123
+
124
+ For most users, the recommended interface is the custom Hugging Face Pipeline shown in the Quick Start example.
125
+
126
+ ---
127
+
128
+ # Input Format
129
+
130
+ The pipeline accepts a single JSON dictionary.
131
+
132
+ ## Required fields
133
+
134
+ * `crop`
135
+ * `weather`
136
+ * `soil`
137
+
138
+ ## Optional fields
139
+
140
+ * `cutoff`
141
+ * `weather_format`
142
+
143
+ ---
144
+
145
+ # Daily Weather Input
146
+
147
+ The recommended input format is `"daily"`.
148
+
149
+ Example:
150
+
151
+ ```json
152
+ {
153
+ "crop": "corn",
154
+ "weather_format": "daily",
155
+ "cutoff": 16,
156
+ "weather": {
157
+ "dayl": [...],
158
+ "prcp": [...],
159
+ "srad": [...],
160
+ "tmax": [...],
161
+ "gdd": [...],
162
+ "tmin": [...],
163
+ "vp": [...],
164
+ "tmean": [...],
165
+ "precip_3day_avg_perday": [...],
166
+ "precip_7day_avg_perday": [...],
167
+ "precip_14day_avg_perday": [...]
168
+ },
169
+ "soil": {
170
+ "ph": 6.5,
171
+ "om": 3.2,
172
+ "cec": 15.0,
173
+ "awc": 0.18,
174
+ "clay": 25.0,
175
+ "p_ppm": 30.0,
176
+ "k_ppm": 150.0,
177
+ "mg_ppm": 220.0,
178
+ "ca_ppm": 1800.0,
179
+ "k_te": 3.0,
180
+ "mg_te": 12.0,
181
+ "ca_te": 70.0,
182
+ "s_ppm": 15.0,
183
+ "zn_ppm": 1.2,
184
+ "fe_ppm": 50.0,
185
+ "mn_ppm": 20.0,
186
+ "cu_ppm": 0.8,
187
+ "b_ppm": 0.5,
188
+ "na_ppm": 10.0,
189
+ "sand": 40.0,
190
+ "silt": 35.0,
191
+ "bd": 1.3,
192
+ "elevation": 280.0,
193
+ "slope": 2.0
194
+ }
195
+ }
196
+ ```
197
+
198
+ The pipeline automatically:
199
+
200
+ * converts daily weather observations into the feature representation expected by the model
201
+ * applies the normalization statistics stored with the model
202
+ * performs inference
203
+ * returns the predicted crop yield
204
+
205
+ No external preprocessing is required.
206
+
207
+ ---
208
+
209
+ # Supported Crops
210
+
211
+ Current supported crop identifiers are:
212
+
213
+ * `corn`
214
+ * `maize`
215
+ * `soy`
216
+ * `soybean`
217
+
218
+ ---
219
+
220
+ # Device Support
221
+
222
+ The pipeline supports both CPU and NVIDIA CUDA GPUs.
223
+
224
+ GPU inference:
225
+
226
+ ```python
227
+ from transformers import pipeline
228
+
229
+ pipe = pipeline(
230
+ "yield-estimation",
231
+ model="Sarikaa-Sridhar/yield-estimation-transformer",
232
+ device=0,
233
+ trust_remote_code=True,
234
+ )
235
+ ```
236
+
237
+ CPU inference:
238
+
239
+ ```python
240
+ from transformers import pipeline
241
+
242
+ pipe = pipeline(
243
+ "yield-estimation",
244
+ model="Sarikaa-Sridhar/yield-estimation-transformer",
245
+ device=-1,
246
+ trust_remote_code=True,
247
+ )
248
+ ```
249
+
250
+ ---
251
+
252
+ # Pipeline Output
253
+
254
+ The pipeline returns a Python dictionary.
255
+
256
+ Example:
257
+
258
+ ```json
259
+ {
260
+ "predicted_yield": 182.28,
261
+ "cutoff": 16,
262
+ "effective_cutoff": 16,
263
+ "crop": "corn",
264
+ "weather_format": "daily"
265
+ }
266
+ ```
267
+
268
+ ---
269
+
270
+ # Acknowledgements
271
+
272
+ This work was developed as part of the ICICLE AI Institute.
273
+
274
+ *National Science Foundation (NSF) AI Institute for Intelligent Cyberinfrastructure with Computational Learning in the Environment (ICICLE), Award OAC-2112606.*
275
+
276
+ ```
277
+ ```
config.json ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "K": 22,
3
+ "S": 24,
4
+ "W": 22,
5
+ "architectures": [
6
+ "YieldForRegression"
7
+ ],
8
+ "crop_emb_dim": 8,
9
+ "d_model": 64,
10
+ "dim_ff": 128,
11
+ "dropout": 0.4,
12
+ "dtype": "float32",
13
+ "eval_cutoffs": [
14
+ 8,
15
+ 12,
16
+ 16,
17
+ 22
18
+ ],
19
+ "model_type": "yield-weather-soil",
20
+ "nhead": 4,
21
+ "num_layers": 4,
22
+ "pool": "last",
23
+ "s_mean": [
24
+ 6.847164154052734,
25
+ 3.9301836490631104,
26
+ 15.024548530578613,
27
+ 0.11498195677995682,
28
+ 28.22588348388672,
29
+ 47.32505798339844,
30
+ 165.28575134277344,
31
+ 439.181640625,
32
+ 2344.830322265625,
33
+ 2.625120162963867,
34
+ 21.69441032409668,
35
+ 60.720855712890625,
36
+ 8.925610542297363,
37
+ 2.972254991531372,
38
+ 195.54396057128906,
39
+ 50.68339157104492,
40
+ 3.3951573371887207,
41
+ 1.1097495555877686,
42
+ 11.323736190795898,
43
+ 28.845870971679688,
44
+ 42.91221618652344,
45
+ 1.6110131740570068,
46
+ 3.024193048477173,
47
+ 0.01876676268875599
48
+ ],
49
+ "s_std": [
50
+ 0.3154756426811218,
51
+ 1.066802740097046,
52
+ 2.689357280731201,
53
+ 0.02995595894753933,
54
+ 6.459582328796387,
55
+ 12.300628662109375,
56
+ 34.140281677246094,
57
+ 138.90280151367188,
58
+ 493.52239990234375,
59
+ 0.7019332051277161,
60
+ 4.397585868835449,
61
+ 7.440662860870361,
62
+ 1.4173370599746704,
63
+ 2.256568431854248,
64
+ 34.21792221069336,
65
+ 20.011821746826172,
66
+ 1.11983060836792,
67
+ 0.5050033926963806,
68
+ 11.268291473388672,
69
+ 12.573676109313965,
70
+ 7.386580467224121,
71
+ 0.09427518397569656,
72
+ 1.7381932735443115,
73
+ 0.013877413235604763
74
+ ],
75
+ "soil_vars": [
76
+ "ph",
77
+ "om",
78
+ "cec",
79
+ "awc",
80
+ "clay",
81
+ "p_ppm",
82
+ "k_ppm",
83
+ "mg_ppm",
84
+ "ca_ppm",
85
+ "k_te",
86
+ "mg_te",
87
+ "ca_te",
88
+ "s_ppm",
89
+ "zn_ppm",
90
+ "fe_ppm",
91
+ "mn_ppm",
92
+ "cu_ppm",
93
+ "b_ppm",
94
+ "na_ppm",
95
+ "sand",
96
+ "silt",
97
+ "bd",
98
+ "elevation",
99
+ "slope"
100
+ ],
101
+ "train_cutoffs": [
102
+ 8,
103
+ 12,
104
+ 16,
105
+ 22
106
+ ],
107
+ "transformers_version": "5.13.0",
108
+ "use_crop": true,
109
+ "w_mean": [
110
+ 49053.73828125,
111
+ 51464.3359375,
112
+ 22.913280487060547,
113
+ 294.0011291503906,
114
+ 362.58697509765625,
115
+ 376.50128173828125,
116
+ 26.46450424194336,
117
+ 26.286815643310547,
118
+ 73.66980743408203,
119
+ 887.6223754882812,
120
+ 14.769508361816406,
121
+ 14.471501350402832,
122
+ 1747.4173583984375,
123
+ 1715.754638671875,
124
+ 20.6231746673584,
125
+ 20.374893188476562,
126
+ 1.1128652095794678,
127
+ 1.2524775266647339,
128
+ 0.47625812888145447,
129
+ 0.5278241634368896,
130
+ 0.23921121656894684,
131
+ 0.2665020227432251
132
+ ],
133
+ "w_std": [
134
+ 4406.49951171875,
135
+ 1378.2545166015625,
136
+ 22.44007110595703,
137
+ 155.5632781982422,
138
+ 53.64530563354492,
139
+ 16.995542526245117,
140
+ 3.947380542755127,
141
+ 2.6959431171417236,
142
+ 27.608686447143555,
143
+ 530.9790649414062,
144
+ 4.153758525848389,
145
+ 2.8188235759735107,
146
+ 401.1476745605469,
147
+ 259.7794189453125,
148
+ 3.952458381652832,
149
+ 2.7360341548919678,
150
+ 1.0096169710159302,
151
+ 0.35198140144348145,
152
+ 0.3805038034915924,
153
+ 0.1445319503545761,
154
+ 0.14427466690540314,
155
+ 0.06612387299537659
156
+ ],
157
+ "weather_vars": [
158
+ "dayl",
159
+ "prcp",
160
+ "srad",
161
+ "tmax",
162
+ "gdd",
163
+ "tmin",
164
+ "vp",
165
+ "tmean",
166
+ "precip_3day_avg_perday",
167
+ "precip_7day_avg_perday",
168
+ "precip_14day_avg_perday"
169
+ ],
170
+ "y_mean": 214.7746124267578,
171
+ "y_std": 32.72690200805664,
172
+ "auto_map": {
173
+ "AutoConfig": "configuration_yield.YieldConfig",
174
+ "AutoModel": "modeling_yield.YieldForRegression"
175
+ },
176
+ "custom_pipelines": {
177
+ "yield-estimation": {
178
+ "impl": "pipeline_yield.YieldEstimationPipeline",
179
+ "pt": [
180
+ "AutoModel"
181
+ ]
182
+ }
183
+ }
184
+ }
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
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:54e7921280a67c7f83d2a50b3823e7384fd8c606328ba22d8c834617c189da9f
3
+ size 723380
modeling_yield.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 configuration_yield import YieldConfig
14
+ from yield_transformer import YieldTransformer
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 = YieldTransformer(
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_norm = (labels - y_mean) / y_std
79
+ loss = nn.functional.mse_loss(logits, labels_norm)
80
+
81
+ if not return_dict:
82
+ return (loss, logits, predictions)
83
+
84
+ return YieldModelOutput(
85
+ loss=loss,
86
+ logits=logits,
87
+ predictions=predictions,
88
+ )
pipeline_yield.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
5
+ if str(PROJECT_ROOT) not in sys.path:
6
+ sys.path.insert(0, str(PROJECT_ROOT))
7
+
8
+ import numpy as np
9
+ import torch
10
+ from transformers import Pipeline
11
+ import math
12
+
13
+ DEFAULT_WEATHER_AGG_RULES = {
14
+ "dayl": "mean",
15
+ "prcp": "sum",
16
+ "srad": "mean",
17
+ "tmax": "mean",
18
+ "tmin": "mean",
19
+ "vp": "mean",
20
+ "tmean": "mean",
21
+ "gdd": "sum",
22
+ "precip_3day_avg_perday": "mean",
23
+ "precip_7day_avg_perday": "mean",
24
+ "precip_14day_avg_perday": "mean",
25
+ }
26
+
27
+ def daily_to_cumulative_weekly(
28
+ daily: np.ndarray,
29
+ agg: str,
30
+ week_len: int = 7,
31
+ ) -> np.ndarray:
32
+ daily = daily.astype(np.float32)
33
+ T = daily.shape[0]
34
+ K = int(math.ceil(T / week_len))
35
+
36
+ weekly = np.zeros(K, dtype=np.float32)
37
+ cumulative = np.zeros(K, dtype=np.float32)
38
+
39
+ for w in range(K):
40
+ s = w * week_len
41
+ e = min((w + 1) * week_len, T)
42
+
43
+ week_chunk = daily[s:e]
44
+ cumulative_chunk = daily[:e]
45
+
46
+ if agg == "sum":
47
+ weekly[w] = np.nansum(week_chunk)
48
+ cumulative[w] = np.nansum(cumulative_chunk)
49
+ else:
50
+ weekly[w] = np.nanmean(week_chunk)
51
+ cumulative[w] = np.nanmean(cumulative_chunk)
52
+
53
+ return np.stack([weekly, cumulative], axis=1).astype(np.float32)
54
+
55
+ class YieldEstimationPipeline(Pipeline):
56
+ """
57
+ Pipeline API for yield prediction using weather + soil inputs.
58
+
59
+ Supports:
60
+ - old models: one feature per weather variable, W = len(weather_vars)
61
+ - new models: weekly + cumulative per weather variable, W = 2 * len(weather_vars)
62
+ """
63
+
64
+ def _sanitize_parameters(self, cutoff=None, weather_format=None, **kwargs):
65
+ preprocess_kwargs = {}
66
+
67
+ if cutoff is not None:
68
+ preprocess_kwargs["cutoff"] = cutoff
69
+
70
+ if weather_format is not None:
71
+ preprocess_kwargs["weather_format"] = weather_format
72
+
73
+ return preprocess_kwargs, {}, {}
74
+
75
+ def preprocess(self, inputs, cutoff=None, weather_format=None):
76
+ cfg = self.model.config
77
+
78
+ if not isinstance(inputs, dict):
79
+ raise ValueError("Input must be a dictionary.")
80
+
81
+ if "weather" not in inputs:
82
+ raise ValueError("Input must contain a 'weather' dictionary.")
83
+
84
+ if "soil" not in inputs:
85
+ raise ValueError("Input must contain a 'soil' dictionary.")
86
+
87
+ requested_cutoff = inputs.get("cutoff", cutoff)
88
+ weather_format = inputs.get(
89
+ "weather_format",
90
+ weather_format if weather_format is not None else "weekly_cumulative",
91
+ )
92
+
93
+ n_weather_vars = len(cfg.weather_vars)
94
+ model_w_dim = int(cfg.W)
95
+
96
+ use_weekly_and_cumulative = model_w_dim == 2 * n_weather_vars
97
+ use_cumulative_only = model_w_dim == n_weather_vars
98
+
99
+ if not (use_weekly_and_cumulative or use_cumulative_only):
100
+ raise ValueError(
101
+ f"Unsupported weather dimension. Model has W={model_w_dim}, "
102
+ f"but len(weather_vars)={n_weather_vars}. Expected W={n_weather_vars} "
103
+ f"or W={2 * n_weather_vars}."
104
+ )
105
+
106
+ weather_cols = []
107
+
108
+ for v in cfg.weather_vars:
109
+ if v not in inputs["weather"]:
110
+ raise ValueError(f"Missing weather variable: {v}")
111
+
112
+ arr = np.asarray(inputs["weather"][v], dtype=np.float32)
113
+
114
+ if weather_format == "daily":
115
+ agg = DEFAULT_WEATHER_AGG_RULES.get(v, "mean")
116
+ arr = daily_to_cumulative_weekly(arr, agg=agg, week_len=7)
117
+
118
+ if arr.ndim != 2 or arr.shape[1] != 2:
119
+ raise ValueError(
120
+ f"daily_to_cumulative_weekly for '{v}' should return shape [K, 2], "
121
+ f"but got {arr.shape}."
122
+ )
123
+
124
+ if use_weekly_and_cumulative:
125
+ weather_cols.append(arr[:, 0]) # weekly
126
+ weather_cols.append(arr[:, 1]) # cumulative
127
+ else:
128
+ weather_cols.append(arr[:, 1]) # cumulative only
129
+
130
+ elif weather_format == "weekly_cumulative":
131
+ arr = np.asarray(arr, dtype=np.float32)
132
+
133
+ if use_cumulative_only:
134
+ if arr.ndim != 1:
135
+ raise ValueError(
136
+ f"For W={model_w_dim}, weather variable '{v}' must be 1D, "
137
+ f"but got shape {arr.shape}."
138
+ )
139
+ weather_cols.append(arr)
140
+
141
+ else:
142
+ # For W=22 models, weekly_cumulative input must provide either:
143
+ # 1. a [K, 2] array for each variable, or
144
+ # 2. separate keys: var_weekly and var_cumulative.
145
+ if arr.ndim == 2 and arr.shape[1] == 2:
146
+ weather_cols.append(arr[:, 0])
147
+ weather_cols.append(arr[:, 1])
148
+ else:
149
+ weekly_key = f"{v}_weekly"
150
+ cumulative_key = f"{v}_cumulative"
151
+
152
+ if weekly_key not in inputs["weather"] or cumulative_key not in inputs["weather"]:
153
+ raise ValueError(
154
+ f"Model expects weekly + cumulative features for '{v}'. "
155
+ f"For weather_format='weekly_cumulative', provide either "
156
+ f"weather['{v}'] as shape [K, 2], or provide "
157
+ f"weather['{weekly_key}'] and weather['{cumulative_key}']."
158
+ )
159
+
160
+ weekly = np.asarray(inputs["weather"][weekly_key], dtype=np.float32)
161
+ cumulative = np.asarray(inputs["weather"][cumulative_key], dtype=np.float32)
162
+
163
+ if weekly.ndim != 1 or cumulative.ndim != 1:
164
+ raise ValueError(
165
+ f"{weekly_key} and {cumulative_key} must both be 1D."
166
+ )
167
+
168
+ weather_cols.append(weekly)
169
+ weather_cols.append(cumulative)
170
+
171
+ else:
172
+ raise ValueError(
173
+ "weather_format must be either 'daily' or 'weekly_cumulative'."
174
+ )
175
+
176
+ lengths = [len(x) for x in weather_cols]
177
+ if len(set(lengths)) != 1:
178
+ raise ValueError(
179
+ f"Weather variable lengths do not match after preprocessing: {lengths}"
180
+ )
181
+
182
+ weather = np.stack(weather_cols, axis=1).astype(np.float32)
183
+
184
+ if requested_cutoff is None:
185
+ cutoff = weather.shape[0]
186
+ else:
187
+ cutoff = int(requested_cutoff)
188
+
189
+ t_eff = min(cutoff, weather.shape[0])
190
+
191
+ if weather.shape[1] != model_w_dim:
192
+ raise ValueError(
193
+ f"Processed weather has shape {weather.shape}, but model expects W={model_w_dim}."
194
+ )
195
+
196
+ soil = []
197
+ for v in cfg.soil_vars:
198
+ if v not in inputs["soil"]:
199
+ raise ValueError(f"Missing soil variable: {v}")
200
+ soil.append(float(inputs["soil"][v]))
201
+
202
+ soil = np.asarray(soil, dtype=np.float32)
203
+
204
+ w_mean = np.asarray(cfg.w_mean, dtype=np.float32)
205
+ w_std = np.asarray(cfg.w_std, dtype=np.float32)
206
+ s_mean = np.asarray(cfg.s_mean, dtype=np.float32)
207
+ s_std = np.asarray(cfg.s_std, dtype=np.float32)
208
+
209
+ if len(w_mean) != weather.shape[1]:
210
+ raise ValueError(
211
+ f"Normalization mismatch: len(w_mean)={len(w_mean)}, "
212
+ f"but weather feature dimension={weather.shape[1]}."
213
+ )
214
+
215
+ weather = np.where(np.isnan(weather), w_mean[None, :], weather)
216
+ weather = (weather - w_mean[None, :]) / w_std[None, :]
217
+
218
+ soil = np.where(np.isnan(soil), s_mean, soil)
219
+ soil = (soil - s_mean) / s_std
220
+
221
+ crop_map = {
222
+ "corn": 0,
223
+ "maize": 0,
224
+ "soybean": 1,
225
+ "soy": 1,
226
+ }
227
+
228
+ crop = str(inputs.get("crop", "corn")).strip().lower()
229
+ crop_id = crop_map.get(crop, 0)
230
+
231
+ return {
232
+ "weather": torch.from_numpy(weather[:t_eff]).unsqueeze(0),
233
+ "soil": torch.from_numpy(soil).unsqueeze(0),
234
+ "crop_id": torch.tensor([crop_id], dtype=torch.long),
235
+ "horizon_idx": t_eff,
236
+ "cutoff": cutoff,
237
+ "effective_cutoff": t_eff,
238
+ "crop": crop,
239
+ "weather_format": weather_format,
240
+ "weather_shape": list(weather[:t_eff].shape),
241
+ }
242
+
243
+ def _forward(self, model_inputs):
244
+ cutoff = model_inputs.pop("cutoff")
245
+ effective_cutoff = model_inputs.pop("effective_cutoff")
246
+ crop = model_inputs.pop("crop")
247
+ weather_format = model_inputs.pop("weather_format")
248
+ weather_shape = model_inputs.pop("weather_shape")
249
+
250
+ outputs = self.model(
251
+ weather=model_inputs["weather"],
252
+ soil=model_inputs["soil"],
253
+ crop_id=model_inputs["crop_id"],
254
+ horizon_idx=model_inputs["horizon_idx"],
255
+ causal=True,
256
+ return_sequence=False,
257
+ )
258
+
259
+ return {
260
+ "outputs": outputs,
261
+ "cutoff": cutoff,
262
+ "effective_cutoff": effective_cutoff,
263
+ "crop": crop,
264
+ "weather_format": weather_format,
265
+ "weather_shape": weather_shape,
266
+ }
267
+
268
+ def postprocess(self, model_outputs):
269
+ pred = float(model_outputs["outputs"].predictions.detach().cpu().item())
270
+
271
+ return {
272
+ "predicted_yield": pred,
273
+ "cutoff": int(model_outputs["cutoff"]),
274
+ "effective_cutoff": int(model_outputs["effective_cutoff"]),
275
+ "crop": model_outputs["crop"],
276
+ "weather_format": model_outputs["weather_format"],
277
+ "weather_shape": model_outputs["weather_shape"],
278
+ }
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
sample_input_daily.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "crop": "corn",
3
+ "weather_format": "daily",
4
+ "weather": {
5
+ "dayl": [45000,45100,45200,45300,45400,45500,45600,45700,45800,45900,46000,46100,46200,46300,46400,46500,46600,46700,46800,46900,47000,47100,47200,47300,47400,47500,47600,47700],
6
+ "prcp": [1,0,3,2,0,5,1,0,2,1,4,0,0,3,2,1,0,4,1,0,2,1,0,3,2,1,0,4],
7
+ "srad": [300,305,310,315,320,325,330,335,340,345,350,355,360,365,370,375,380,385,390,395,400,405,410,415,420,425,430,435],
8
+ "tmax": [25,26,27,28,29,30,29,28,27,26,28,29,30,31,32,31,30,29,28,27,26,25,26,27,28,29,30,31],
9
+ "gdd": [10,12,13,14,15,16,15,14,13,12,14,15,16,17,18,17,16,15,14,13,12,11,12,13,14,15,16,17],
10
+ "tmin": [15,16,16,17,18,19,18,17,16,15,16,17,18,19,20,19,18,17,16,15,14,14,15,16,17,18,19,20],
11
+ "vp": [1000,1010,1020,1030,1040,1050,1040,1030,1020,1010,1020,1030,1040,1050,1060,1050,1040,1030,1020,1010,1000,995,1000,1010,1020,1030,1040,1050],
12
+ "tmean": [20,21,21.5,22.5,23.5,24.5,23.5,22.5,21.5,20.5,22,23,24,25,26,25,24,23,22,21,20,19.5,20.5,21.5,22.5,23.5,24.5,25.5],
13
+ "precip_3day_avg_perday": [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
14
+ "precip_7day_avg_perday": [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
15
+ "precip_14day_avg_perday": [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
16
+ },
17
+ "soil": {
18
+ "ph": 6.5,
19
+ "om": 3.2,
20
+ "cec": 15.0,
21
+ "awc": 0.18,
22
+ "clay": 25.0,
23
+ "p_ppm": 30.0,
24
+ "k_ppm": 150.0,
25
+ "mg_ppm": 220.0,
26
+ "ca_ppm": 1800.0,
27
+ "k_te": 3.0,
28
+ "mg_te": 12.0,
29
+ "ca_te": 70.0,
30
+ "s_ppm": 15.0,
31
+ "zn_ppm": 1.2,
32
+ "fe_ppm": 50.0,
33
+ "mn_ppm": 20.0,
34
+ "cu_ppm": 0.8,
35
+ "b_ppm": 0.5,
36
+ "na_ppm": 10.0,
37
+ "sand": 40.0,
38
+ "silt": 35.0,
39
+ "bd": 1.3,
40
+ "elevation": 280.0,
41
+ "slope": 2.0
42
+ }
43
+ }
sample_test.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from transformers import pipeline
3
+
4
+ with open(f"sample_input_daily.json", "r") as f:
5
+ sample = json.load(f)
6
+
7
+ pipe = pipeline(
8
+ "yield-estimation",
9
+ model=".",
10
+ trust_remote_code=True,
11
+ )
12
+
13
+ out = pipe(sample)
14
+ print(json.dumps(out, indent=2))
yield_transformer.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 YieldTransformer(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
+ self.week_proj = nn.Linear(1, d_model)
52
+
53
+ self.weather_enc = MLP(w_dim + crop_in, d_model, hidden=d_model, dropout=dropout)
54
+ self.soil_enc = MLP(soil_dim + crop_in, d_model, hidden=d_model, dropout=dropout)
55
+
56
+ self.ws_cross_attn = nn.MultiheadAttention(
57
+ embed_dim=d_model,
58
+ num_heads=nhead,
59
+ dropout=dropout,
60
+ batch_first=True,
61
+ )
62
+ self.ws_ln = nn.LayerNorm(d_model)
63
+
64
+ self.pos_emb = nn.Parameter(torch.zeros(1, max_weeks + 1, d_model))
65
+ nn.init.trunc_normal_(self.pos_emb, std=0.02)
66
+
67
+ if pool == "cls":
68
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, d_model))
69
+ nn.init.trunc_normal_(self.cls_token, std=0.02)
70
+
71
+ enc_layer = nn.TransformerEncoderLayer(
72
+ d_model=d_model, nhead=nhead, dim_feedforward=dim_ff,
73
+ dropout=dropout, activation="gelu", batch_first=True, norm_first=True,
74
+ )
75
+ self.temporal_tf = nn.TransformerEncoder(enc_layer, num_layers=num_layers)
76
+
77
+ self.head = nn.Sequential(
78
+ nn.LayerNorm(d_model + horizon_emb_dim + d_model),
79
+ nn.Linear(d_model + horizon_emb_dim + d_model, d_model),
80
+ nn.GELU(),
81
+ nn.Dropout(dropout),
82
+ nn.Linear(d_model, 1),
83
+ )
84
+
85
+ def forward(
86
+ self,
87
+ weather,
88
+ soil,
89
+ crop_id,
90
+ farm_field_encoded=None,
91
+ week_mask=None,
92
+ causal=False,
93
+ return_sequence=False,
94
+ horizon_idx=None,
95
+ ):
96
+ B, t, W = weather.shape
97
+ device = weather.device
98
+
99
+
100
+ if horizon_idx is None: horizon_idx = torch.tensor(t, device=device).expand(B)
101
+ if not torch.is_tensor(horizon_idx): horizon_idx = torch.tensor(horizon_idx, device=device).expand(B)
102
+
103
+ h_idx = horizon_idx.long().clamp(min=1, max=self.max_weeks)
104
+ h_emb = self.horizon_emb(h_idx)
105
+ h_tok = self.horizon_proj(h_emb)
106
+
107
+ if self.use_crop:
108
+ c = self.crop_emb(crop_id)
109
+ weather_in = torch.cat([weather, c[:, None, :].expand(-1, t, -1)], dim=-1)
110
+ soil_in = torch.cat([soil, c], dim=-1)
111
+ else:
112
+ weather_in, soil_in = weather, soil
113
+
114
+ w_tok = self.weather_enc(weather_in)
115
+ s_encoded = self.soil_enc(soil_in)
116
+
117
+ fused = self.ws_ln(w_tok + s_encoded[:, None, :])
118
+ fused = fused + h_tok[:, None, :]
119
+
120
+ weeks = torch.arange(1, t + 1, device=device).float() / self.max_weeks
121
+ week_signal = self.week_proj(weeks[None, :, None].expand(B, -1, -1))
122
+
123
+ x = fused + week_signal + self.pos_emb[:, :t, :]
124
+
125
+ if self.pool == "cls":
126
+ x = torch.cat([self.cls_token.expand(B, -1, -1), x], dim=1)
127
+ t_idx = t + 1
128
+ else: t_idx = t
129
+
130
+ src_key_padding_mask = ~week_mask.bool() if week_mask is not None else None
131
+
132
+ attn_mask = None
133
+ if causal:
134
+ L = x.size(1)
135
+ attn_mask = torch.triu(
136
+ torch.ones(L, L, device=device, dtype=torch.bool),
137
+ diagonal=1,
138
+ )
139
+
140
+ h = self.temporal_tf(
141
+ x,
142
+ mask=attn_mask,
143
+ src_key_padding_mask=src_key_padding_mask,
144
+ )
145
+
146
+ if self.pool == "mean":
147
+ if week_mask is not None:
148
+ mask = week_mask.unsqueeze(-1).float()
149
+ pooled = (h * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9)
150
+ else:
151
+ pooled = h.mean(dim=1)
152
+ elif self.pool == "cls":
153
+ pooled = h[:, 0, :]
154
+ else:
155
+ pooled = h[:, -1, :]
156
+
157
+ out = torch.cat([pooled, h_emb, s_encoded], dim=-1)
158
+ return self.head(out).squeeze(-1)
159
+