deepSSF 0.2.3__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. deepssf-0.3.1/CHANGELOG.md +334 -0
  2. {deepssf-0.2.3 → deepssf-0.3.1}/CITATION.cff +1 -1
  3. deepssf-0.3.1/Habitat_movement_loss_balancing.md +237 -0
  4. {deepssf-0.2.3 → deepssf-0.3.1}/PKG-INFO +4 -2
  5. deepssf-0.3.1/examples/deepssf_train_validate_example_v0_3_0.ipynb +2964 -0
  6. deepssf-0.3.1/examples/deepssf_train_validate_example_v0_3_1.ipynb +2982 -0
  7. {deepssf-0.2.3 → deepssf-0.3.1}/pyproject.toml +5 -0
  8. {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/__init__.py +33 -2
  9. {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/data.py +222 -65
  10. deepssf-0.3.1/src/deepssf/model.py +592 -0
  11. deepssf-0.3.1/src/deepssf/plot.py +420 -0
  12. deepssf-0.3.1/src/deepssf/predict.py +178 -0
  13. deepssf-0.3.1/src/deepssf/simulate.py +983 -0
  14. deepssf-0.3.1/src/deepssf/train.py +1596 -0
  15. {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/utils.py +27 -2
  16. {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/validate.py +13 -5
  17. deepssf-0.3.1/tests/test_deepssf.py +2717 -0
  18. deepssf-0.2.3/CHANGELOG.md +0 -10
  19. deepssf-0.2.3/examples/deepssf_train_validate_example.ipynb +0 -2523
  20. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-15-output-1.png +0 -0
  21. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-17-output-1.png +0 -0
  22. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-19-output-1.png +0 -0
  23. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-21-output-1.png +0 -0
  24. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-23-output-1.png +0 -0
  25. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-24-output-1.png +0 -0
  26. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-27-output-1.png +0 -0
  27. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-28-output-1.png +0 -0
  28. deepssf-0.2.3/examples/deepssf_train_validate_example_files/figure-html/cell-30-output-1.png +0 -0
  29. deepssf-0.2.3/src/deepssf/model.py +0 -362
  30. deepssf-0.2.3/src/deepssf/simulate.py +0 -313
  31. deepssf-0.2.3/src/deepssf/train.py +0 -576
  32. deepssf-0.2.3/tests/test_deepssf.py +0 -842
  33. {deepssf-0.2.3 → deepssf-0.3.1}/.github/workflows/ci.yml +0 -0
  34. {deepssf-0.2.3 → deepssf-0.3.1}/.github/workflows/publish.yml +0 -0
  35. {deepssf-0.2.3 → deepssf-0.3.1}/.gitignore +0 -0
  36. {deepssf-0.2.3 → deepssf-0.3.1}/.pre-commit-config.yaml +0 -0
  37. {deepssf-0.2.3 → deepssf-0.3.1}/LICENSE +0 -0
  38. {deepssf-0.2.3 → deepssf-0.3.1}/Package_generation_process.md +0 -0
  39. {deepssf-0.2.3 → deepssf-0.3.1}/README.md +0 -0
  40. {deepssf-0.2.3 → deepssf-0.3.1}/environment.yml +0 -0
  41. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example.html +0 -0
  42. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/figure-html/cell-25-output-1.png +0 -0
  43. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/figure-html/cell-29-output-1.png +0 -0
  44. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/figure-html/cell-32-output-1.png +0 -0
  45. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/figure-html/cell-7-output-1.png +0 -0
  46. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-138a6193a3bd40baf1e627da441a4734.min.css +0 -0
  47. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-icons.css +0 -0
  48. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap-icons.woff +0 -0
  49. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/bootstrap/bootstrap.min.js +0 -0
  50. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/clipboard/clipboard.min.js +0 -0
  51. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/anchor.min.js +0 -0
  52. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/popper.min.js +0 -0
  53. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/quarto-syntax-highlighting-7f8f88aac4f3542376d5c11b86a4c14d.css +0 -0
  54. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/quarto.js +0 -0
  55. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/tabsets/tabsets.js +0 -0
  56. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/tippy.css +0 -0
  57. {deepssf-0.2.3 → deepssf-0.3.1}/examples/deepssf_train_validate_example_files/libs/quarto-html/tippy.umd.min.js +0 -0
  58. {deepssf-0.2.3 → deepssf-0.3.1}/examples/references.bib +0 -0
  59. {deepssf-0.2.3 → deepssf-0.3.1}/icons/both_icons.png +0 -0
  60. {deepssf-0.2.3 → deepssf-0.3.1}/icons/deepSSF_hex_textured_Python.png +0 -0
  61. {deepssf-0.2.3 → deepssf-0.3.1}/icons/deepSSF_hex_textured_R.png +0 -0
  62. {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/datasets/data/buffalo_djelk_id2005.csv +0 -0
  63. {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/datasets/data/ndvi_2005.tif +0 -0
  64. {deepssf-0.2.3 → deepssf-0.3.1}/src/deepssf/datasets/data/slope_2005.tif +0 -0
  65. {deepssf-0.2.3 → deepssf-0.3.1}/styles.css +0 -0
  66. {deepssf-0.2.3 → deepssf-0.3.1}/tests/__init__.py +0 -0
  67. {deepssf-0.2.3 → deepssf-0.3.1}/tests/test_smoke.py +0 -0
@@ -0,0 +1,334 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.3.1] - 2026-10-23
8
+
9
+ ### Added
10
+ - **Step length and turning angle columns** — `prepare_movement_df` now returns
11
+ `sl_` (step length, the Euclidean length of `(dx, dy)`, placed after `dx`/`dy`)
12
+ and `ta_` (turning angle, placed after `bearing_tm1`). `ta_` is
13
+ `bearing - bearing_tm1` wrapped to [-π, π), so a slight turn across the ±π
14
+ boundary is not reported as a near-full rotation; positive values are
15
+ anticlockwise turns, following the `arctan2` bearing convention. It is NaN for
16
+ each individual's first step, which has no previous bearing. Neither column is
17
+ a model input.
18
+ - **`tz` argument to `prepare_movement_df`** — the timezone to express
19
+ timestamps and time-derived columns in, e.g. `"Australia/Darwin"`. When
20
+ omitted, the timezone of a tz-aware time column is kept; tz-naive times are
21
+ still treated as UTC.
22
+ - **Timestamps on simulated trajectories** — `simulate_trajectory`,
23
+ `simulate_trajectories_batched` and `simulate_trajectories` take an optional
24
+ `start_time` and, when it is given, add a `time` column (before `hour`) rolled
25
+ forward from it by `time_between_steps`. Rolling forward rather than rebuilding
26
+ from `yday`/`hour` keeps the year (which `yday` loses when it wraps at 365)
27
+ and the timezone of `start_time`. It only labels the output — the model still
28
+ reads `starting_yday`/`starting_hour`, so these should describe the same
29
+ moment. `simulate_trajectories` accepts one start time per trajectory under
30
+ `method="sequential"`/`"parallel"`; `"batched"` requires a single value, as it
31
+ does for the other start-time arguments.
32
+ - **Example notebook `deepssf_train_validate_example_v0_3_1.ipynb`** — adds
33
+ diagnostic summaries of the observed and simulated data: step length and
34
+ turning angle histograms, and both by hour of day. Turning angles are
35
+ summarised per hour by the circular SD and the shortest arc containing 50% of
36
+ the angles (which may wrap across ±π), since a mean or median sits near zero
37
+ whatever the spread.
38
+
39
+ ### Fixed
40
+ - **Times were converted to UTC in `prepare_movement_df`.** Timestamps were
41
+ parsed with `utc=True` and the input's timezone was never restored, so a
42
+ tz-aware time column (e.g. converted to `Australia/Darwin`) still produced
43
+ `hour_t1`, `yday_t1` and their sine/cosine encodings in UTC — 9.5 h out for
44
+ Darwin, misplacing diel patterns such as dawn and dusk activity peaks. These
45
+ columns are now in local time, and `t1_`/`t2_` are tz-aware timestamps
46
+ rather than the raw input values. The Sentinel-2 month look-up in
47
+ `MovementDataset`, which reads `t1_`, now uses the local month too.
48
+ **Models trained with ≤ 0.3.0 on tz-aware times learned UTC hour encodings**,
49
+ so retrain them (and re-run any simulations) before comparing against data
50
+ prepared with this version.
51
+
52
+ ## [0.3.0] - 2026-10-23
53
+
54
+ ### Added
55
+ - **Configurable convolutional depth** — `ModelParams` now takes
56
+ `n_conv_layers_hab` and `n_conv_layers_move`, setting the depth of the two
57
+ sub-networks independently. The habitat count includes the final convolution
58
+ that projects to one output channel (so the minimum is 1), and since that
59
+ block does no pooling, extra layers only deepen the features and widen the
60
+ receptive field. The movement count is the number of conv → ReLU → max-pool
61
+ blocks, so it also controls how far the window is reduced before the
62
+ fully-connected layers. Both keys are optional and default to the paper
63
+ architecture (4 and 3), so existing params dicts and checkpoints are
64
+ unaffected — but note that a checkpoint only loads into a model with the same
65
+ layer counts it was trained with.
66
+ - **`deepssf.model.flattened_conv_dim`** — compute the flattened size of the
67
+ movement CNN's output, i.e. the value `dense_dim_in_all` must be set to. It
68
+ changes with `n_conv_layers_move` (every layer pools), so it can no longer be
69
+ worked out once and hard-coded. It is derived arithmetically rather than by a
70
+ dummy forward pass, because the answer is needed *before* `ModelParams` is
71
+ built, and it raises if the stack would pool the window away to nothing
72
+ instead of failing later inside `MaxPool2d`.
73
+ - **`deepssf.simulate.trajectory_heatmap`** — count simulated locations per cell
74
+ to give a space-use surface for a simulation. `burn_in` discards the first *n*
75
+ steps of every trajectory, which otherwise pile up around the release site(s)
76
+ and describe the starting conditions more than the fitted model; `agg`
77
+ aggregates *agg* x *agg* raster pixels into one heatmap cell, since at a
78
+ typical 20 m resolution the counts are spread so thinly that nearly every
79
+ occupied cell holds a single location. The returned transform keeps the
80
+ landscape rasters' origin, so the heatmap stays aligned with the layers it was
81
+ simulated on. Locations that walked off the landscape are dropped and reported.
82
+ - **`deepssf.data.save_raster`** — write a 2-D or 3-D array to a GeoTIFF with a
83
+ transform, CRS, nodata value and band descriptions, so anything derived from
84
+ the landscape (a habitat selection surface, a simulation heatmap) opens in
85
+ QGIS/ArcGIS on top of the layers it came from. `int64` is narrowed to `int32`,
86
+ which GeoTIFF supports and `int64` does not.
87
+ - **`deepssf.plot.add_heatmap_overlay`** — add a heatmap raster to an existing
88
+ folium map as its own toggleable layer, log-scaled by default and fully
89
+ transparent below `vmin` so unvisited cells do not grey out the basemap.
90
+ Composes with `plot_trajectories_folium`: that function builds its layer
91
+ control before returning, but folium collects layers at render time, so an
92
+ overlay added afterwards still gets a checkbox. The counts are warped to
93
+ EPSG:3857 with rasterio and handed to folium as a **uint8** image rather than
94
+ using `mercator_project`: folium's own re-projection interpolates the RGBA
95
+ image and returns float64, which `write_png` then rescales channel by channel
96
+ — stretching the colours and flattening the alpha channel to fully opaque.
97
+ - **`deepssf.predict.predict_habitat_landscape`** — run the trained habitat
98
+ filters over an entire raster in one pass, giving a landscape-scale habitat
99
+ selection surface. The habitat sub-network is fully convolutional (3x3
100
+ convolutions, stride 1, padding 1, no pooling or dense layer), so it is
101
+ translation-equivariant: a pixel's value is exactly what the model would
102
+ produce for a window centred on it. The scalar covariates are broadcast to
103
+ full-landscape layers, since `Scalar_to_Grid_Block`'s output size is pinned to
104
+ the training window. Processing is chunked by rows with an `edge_buffer`
105
+ overlap — bit-identical to a single pass, but without the several-hundred-MB
106
+ activations that can exhaust a GPU. `habitat_edge_buffer` reports the exact
107
+ border width that zero-padding contaminates (1 px per conv layer, so 4), which
108
+ is masked to NaN rather than the -inf earlier scripts multiplied through.
109
+ - **Checkpoints record the architecture they were trained with** — a checkpoint
110
+ written from a `ConvJointModel` now carries a `model_params` key holding
111
+ `ModelParams.to_dict()`, alongside the weights. Weights only load into a model
112
+ built with the *same* architecture, so predicting from a saved model
113
+ previously meant re-typing every hyper-parameter into the prediction script,
114
+ where a habitat branch one layer too shallow fails loudly but a mis-set
115
+ `pixel_size` quietly rescales the movement kernel. The key is optional rather
116
+ than a checkpoint-format bump: format-2 readers ignore what they do not know,
117
+ and files written before it existed still load.
118
+ - **`deepssf.train.params_from_checkpoint`** — rebuild the `ModelParams` a
119
+ checkpoint was trained with, ready to pass to `ConvJointModel`. Reads
120
+ `model_params` where present, and otherwise recovers the architecture from the
121
+ layer shapes themselves — a conv weight is `[out_channels, in_channels, k, k]`
122
+ and a linear weight `[out_features, in_features]`, which between them pin down
123
+ every hyper-parameter that changes a tensor shape. Either way the result is
124
+ cross-checked against the saved tensors, so a params dict that contradicts the
125
+ weights it sits beside raises instead of failing later at `load_state_dict`.
126
+ `image_dim` and `pixel_size` leave no trace in the weights and must be given
127
+ for a checkpoint that has no stored params; `image_dim` is then validated
128
+ against the flattened size the movement MLP expects, which pins it to within
129
+ the slack that max-pooling's floor division allows. `device` deliberately
130
+ defaults to this machine rather than to whatever the model was trained on.
131
+ - **`ModelParams.to_dict()`** — a plain-Python copy of the hyper-parameters that
132
+ round-trips through `ModelParams(...)`. Values are coerced to built-in types
133
+ (a `torch.device` or NumPy scalar becomes a `str` or `float`) because
134
+ checkpoints are read back with `torch.load(weights_only=True)`, which accepts
135
+ tensors and built-in containers only. `ModelParams.FIELDS` lists the fields, so
136
+ a new hyper-parameter reaches checkpoints as soon as it is added there.
137
+ - **`EarlyStopping(checkpoint_on=...)`** and **`EarlyStopping(head_paths=...)`**
138
+ — see *Fixed* below.
139
+ - **`load_head_weights`** — copy just one head's weights out of a checkpoint,
140
+ leaving the other head untouched. `HEAD_MODULE_PREFIXES` records the
141
+ module-to-head mapping, mirroring the split `set_trainable` freezes on.
142
+ - **`simulate_trajectories`** — simulate many trajectories in one call, returning
143
+ them stacked in a long-format DataFrame with `trajectory_id` and `step`
144
+ columns. Three methods: `"batched"` (the default), `"sequential"` (a plain loop
145
+ over `simulate_trajectory`) and `"parallel"` (that loop over a thread pool).
146
+ - **`simulate_trajectories_batched`** — steps every trajectory together so the
147
+ model sees a batch of windows per forward pass instead of one. Measured on an
148
+ Apple M-series GPU at `window_size=75`, 200 steps: 2.2× faster than the
149
+ sequential loop at 4 trajectories, 6.9× at 16 and 34× at 64, since the cost per
150
+ step is nearly flat in the number of trajectories. Window extraction is a
151
+ single advanced-indexing gather over a landscape padded once per month, and
152
+ sampling uses `torch.multinomial` over the whole batch. The trade-off is a
153
+ shared clock: `starting_yday`/`starting_hour` must be scalars (start
154
+ *locations* may still vary per trajectory).
155
+ Note that the sub-pixel jitter is scaled from the raster resolution implied by
156
+ the transform, whereas `simulate_next_step` hard-codes a 25 m cell — so batched
157
+ and sequential runs differ slightly on rasters of any other resolution.
158
+ - **`save_trajectories`** — write simulated trajectories to CSV under a name that
159
+ records trajectory count, step count and date, appending a run counter rather
160
+ than ever overwriting an existing file. `split=True` writes one file per
161
+ trajectory.
162
+ - **`deepssf.plot.plot_trajectories_folium`** — draw simulated and observed
163
+ trajectories over a satellite (or topographic/OSM) basemap, reprojecting from
164
+ the data's projected CRS to EPSG:4326 via `rasterio.warp` (no pyproj axis-order
165
+ trap). Each trajectory is its own toggleable layer. `folium` is an optional
166
+ dependency: `pip install "deepSSF[maps]"`.
167
+
168
+ ### Fixed
169
+ - **DataLoaders fail with `affine` 3.x** — `MovementDataset` items carried the
170
+ raster's `Affine` transform as their fifth element, and since `affine` 3.0
171
+ `Affine` is no longer a tuple, so PyTorch's `default_collate` raised a
172
+ `TypeError` on the first batch. The fifth element is now a `[6]` float64
173
+ tensor of the transform's coefficients `(a, b, c, d, e, f)`, which batches on
174
+ any `affine` version; rebuild the transform with `Affine(*coeffs.tolist())`,
175
+ or use `dataset.raster_transform`. The training and validation loops ignore
176
+ this element, so they are unaffected.
177
+ - **Sub-pixel jitter was hard-coded to a 25 m cell.** `simulate_next_step`
178
+ scattered each sampled location by a truncated normal on [0, 25] m regardless
179
+ of the raster's actual resolution. On a 20 m raster that put up to 1.25 cells
180
+ of noise on every simulated step and pushed locations outside the cell the
181
+ model had sampled; on a 100 m raster it never left the first quarter of the
182
+ cell. The cell size now comes from the transform, which is the authority on
183
+ the grid the model was trained against. **Simulated trajectories will differ
184
+ from previous versions on any raster that is not 25 m.**
185
+ - **A habitat-only training stage saved the wrong epoch.** The combined loss is
186
+ `habitat + movement + logZ`, and `logZ = logsumexp(habitat + movement)` — the
187
+ overlap between the habitat surface and the movement kernel — moves with the
188
+ habitat weights. The combined loss and the habitat loss are the same
189
+ used-versus-available contrast measured against different availability sets:
190
+ the combined loss scores habitat against the cells within reach (~109 effective
191
+ cells of a 75x75 window on a feral-pig model), the habitat loss against the
192
+ whole window (~5460). So with movement frozen the combined loss can keep
193
+ falling — habitat discriminating better among near neighbours — while the
194
+ density it gives the observed location gets *worse*. Since checkpointing keyed
195
+ on the combined loss unconditionally, every epoch of such a stage looked like an
196
+ improvement and overwrote the checkpoint, ending on a habitat surface well past
197
+ its best — in one feral-pig run, habitat bottomed out at epoch 12 of a 10-epoch
198
+ stage and then gave back three quarters of what it had learned while the
199
+ combined loss kept falling.
200
+ `EarlyStopping(checkpoint_on='active')` now keys the checkpoint on whichever
201
+ head is training during a single-head stage, falling back to the combined loss
202
+ when both are; `head_paths=` additionally keeps each head's own best epoch in
203
+ its own file. The default remains `'total'` — for the finished *joint* model,
204
+ and for a habitat surface intended as a *selection* map, that is still the
205
+ right criterion; `'active'` is mainly a diagnostic for whether the habitat head
206
+ is learning at all. `negativeLogLikeLoss`'s docstring now spells out what `logZ`
207
+ is, gives the gradients of both criteria, and notes that neither is a spatial
208
+ operation — the habitat CNN has no positional input and is
209
+ translation-equivariant, so the difference between them is entirely in which
210
+ covariate values enter the available side of the contrast.
211
+ - **Environmental layers were not scaled to [0, 1].** `load_environmental_layers`
212
+ divided by the maximum rather than the range — `(data - lo) / hi` instead of
213
+ `(data - lo) / (hi - lo)` — which only lands in [0, 1] when the minimum happens
214
+ to be zero. Sentinel-2 indices stored as ×10 000 (`lo=-10000, hi=10000`) came
215
+ out in [0, 2]; an all-negative layer came out negative *and* axis-flipped,
216
+ reversing the sign of everything the habitat CNN learned from it; a layer with
217
+ `hi == 0` divided by zero. A constant layer now maps to a flat 0 instead of
218
+ raising. **Checkpoints trained on the old scaling will not transfer** — the
219
+ inputs have changed, so retrain.
220
+ - **`conv_movement` was registered with no optimiser.** `make_optimisers` covered
221
+ only `fcn_movement_all` and `conv_habitat`, so the movement CNN trunk received
222
+ gradients on every `backward()` but was never zeroed and never stepped. Its
223
+ `.grad` accumulated monotonically across the whole run: the trunk stayed at its
224
+ random initialisation, and once the buffer overflowed `_grads_are_finite` —
225
+ which iterates over every parameter — began returning `False`, silently
226
+ skipping *every* subsequent update, habitat's included. `conv_movement` now
227
+ sits in the movement optimiser alongside `fcn_movement_all`.
228
+ - `train_loop` zeroes gradients with `model.zero_grad(set_to_none=True)` rather
229
+ than per-optimiser, so no parameter can be left out of the reset however the
230
+ optimisers are configured. The manual stash-and-restore of habitat gradients
231
+ around the two optimiser steps has been removed: the optimisers own disjoint
232
+ parameter sets and `Adam.step()` only reads `.grad` for its own parameters, so
233
+ it was a no-op.
234
+ - **Both learning-rate schedulers stepped on the combined validation loss.** That
235
+ loss is `habitat + movement + logZ` and is dominated by the movement component
236
+ (~1.2 nats of movement against ~0.05 of habitat over a typical run), so the
237
+ habitat learning rate was being cut whenever the *movement* head plateaued.
238
+ Each scheduler now steps on its own component, and only while that component is
239
+ training — stepping a frozen head's scheduler would penalise it for an
240
+ improvement it was never given the chance to make.
241
+ - **NaN movement probability surfaces during training.** In float32 the von
242
+ Mises normaliser `I₀(κ)` overflows to `inf` at κ ≈ 89 — reachable from a raw
243
+ FCN output of only `log(89) ≈ 4.5`. With both mixture components overflowing,
244
+ the log-sum-exp evaluated `-inf - (-inf)` and the entire surface became NaN;
245
+ with only one overflowing, the forward pass stayed finite but produced NaN
246
+ gradients that silently propagated into every weight on the next optimiser
247
+ step. `Params_to_Grid_Block._vonmises_log` now uses the exponentially-scaled
248
+ Bessel function, `log I₀(κ) = κ + log(i0e(κ))`, giving
249
+ `κ·(cos(θ-μ) - 1) - log(2π) - log(i0e(κ))`, whose exponent is ≤ 0 for every κ.
250
+ - Mixture components are combined with `torch.logsumexp` instead of a manual
251
+ max/exp/log, which returned NaN when both components were `-inf`.
252
+ - Raw FCN outputs are clamped (`Params_to_Grid_Block.raw_clamp`, default 10.0)
253
+ before exponentiation. Previously `exp` overflowed to `inf` at a raw value of
254
+ ≈ 88, making `lgamma(inf) - inf` NaN, and scale underflowed to 0 at ≈ -88.
255
+ - `torch.special.i0e` is called on the active device. The previous `.cpu()`
256
+ round-trip for MPS forced a host sync on every forward pass; `i0`/`i0e` now
257
+ have CPU, CUDA and MPS kernels registered (verified on torch 2.12).
258
+ - `distance_layer` and `bearing_layer` are registered buffers, so `model.to()`
259
+ moves them once instead of copying them host→device on every forward pass.
260
+ They use `persistent=False`: both are constants derived from `image_dim` and
261
+ `pixel_size`, so they stay out of `state_dict` and checkpoint keys are
262
+ unchanged.
263
+ - `train_loop` skips batches whose loss or gradients are not finite rather than
264
+ stepping the optimiser on them, and excludes them from the epoch mean. A
265
+ single bad batch no longer makes a run unrecoverable.
266
+ - The epoch mean is now averaged over contributing batches, not `num_batches`.
267
+ - Corrected the `Params_to_Grid_Block_ChV` docstring: it subtracts `log(r)` from
268
+ the log-density (dividing the density by `r`), not "by `log(r)`".
269
+
270
+ ### Changed
271
+ - **Breaking:** `skip_epoch0_training` has been removed from `train_loop`. It had
272
+ no epoch awareness — it was a per-call switch that `fit` never passed, so its
273
+ default applied to *every* epoch, and with that default at `True` an entire run
274
+ executed forward-only under `torch.set_grad_enabled(False)`. It also measured a
275
+ training-set baseline when the useful number is a validation one. `fit` now runs
276
+ one validation pass before the first epoch and records it at index 0 of every
277
+ history list, with `train_losses[0] = nan`.
278
+ - `history` gains a `stage` key recording which stage each epoch belonged to
279
+ (`-1` for the pre-training baseline row).
280
+ - **Breaking (checkpoints):** mixture weights are now `log_softmax` over the raw
281
+ FCN outputs. Previously `softmax(exp(raw))` was applied — a double exponential
282
+ that saturated to exactly `(1, 0)` by a raw value of ≈ 4.75, zeroing the weight
283
+ gradient and putting `log(0) = -inf` into the surface. Movement parameters
284
+ 2, 5, 8 and 11 therefore have a different meaning, so checkpoints written by
285
+ ≤ 0.2.3 will be reinterpreted rather than failing to load. The step-length and
286
+ turning-angle densities themselves are unchanged (agreement to ~6e-6 when the
287
+ two weights are equal).
288
+ - `negativeLogLikeLoss` raises a `RuntimeWarning` once per non-finite surface
289
+ instead of printing on every batch, and performs one device sync per batch
290
+ rather than three.
291
+ - `_expand` uses `expand` rather than `repeat`, returning a view instead of
292
+ materialising a `[B, H, W]` copy per parameter.
293
+
294
+ ## [0.2.3] - 2026-05-31
295
+
296
+ ### Added
297
+ - **`EarlyStopping(monitor='both')`.** Keeps a separate patience budget for the
298
+ habitat and movement components and stops only once every active component has
299
+ plateaued, so a converged movement head can no longer end a run in which habitat
300
+ is still improving. Checkpointing still keys on the combined loss — the joint
301
+ likelihood remains the right model-selection criterion; only the stopping rule
302
+ changes. `reset()` clears the counters at stage boundaries while leaving the
303
+ best score and saved checkpoint intact.
304
+ - **`fit(stages=...)`** runs the sub-networks as a coordinate ascent on the same
305
+ joint likelihood, e.g. movement alone to establish an availability kernel, then
306
+ habitat alone to its own convergence, then both. A frozen branch keeps
307
+ contributing its surface to the loss as a fixed offset; only its weights stop
308
+ moving, so the objective is unchanged rather than reweighted. This is not what
309
+ `negativeLogLikeLoss(freeze_movement=True)` does — that flag *drops* the movement
310
+ surface from the loss, which would make the habitat head learn a distance-decay
311
+ bump in order to explain the movement structure itself. Early stopping ends the
312
+ current stage and advances to the next. `reset_optimiser_state` (default `True`)
313
+ clears the Adam moments of a branch when it becomes active again.
314
+ - `set_trainable(model, *, habitat, movement)` toggles `requires_grad` per
315
+ sub-network. Exported from the package root.
316
+ - See `Habitat_movement_loss_balancing.md` for why the fix for the
317
+ habitat/movement asymmetry is a monitoring change rather than a loss weight.
318
+ - **Versioned checkpoints.** `EarlyStopping` now writes a dict with
319
+ `deepssf_checkpoint_format`, `deepssf_version`, `val_loss` and `state_dict`
320
+ keys instead of a bare `state_dict` (`CHECKPOINT_FORMAT = 2`). Because the
321
+ `state_dict` keys themselves did not change, a pre-0.3.0 file would otherwise
322
+ load without error while silently misreading movement parameters 2, 5, 8
323
+ and 11 — the marker makes that case fail loudly.
324
+ - `load_checkpoint(path, model=None, *, map_location=None, allow_legacy=False,
325
+ strict=True)` reads a checkpoint, verifies its format, and optionally loads it
326
+ into a model. Legacy (format 1) files raise `RuntimeError` unless
327
+ `allow_legacy=True`; files from a future format are refused with an
328
+ upgrade message. Exported from the package root alongside `CHECKPOINT_FORMAT`.
329
+ - `grad_clip` argument on `fit` and `train_loop` for global gradient-norm
330
+ clipping (`None` by default, so existing behaviour is unchanged).
331
+ - Regression tests covering forward and backward finiteness at extreme movement
332
+ parameters, log-normalisation of the surface, and non-vanishing mixture-weight
333
+ gradients.
334
+ - Initial package scaffold.
@@ -10,4 +10,4 @@ authors:
10
10
  repository-code: "https://github.com/swforrest/deepssf"
11
11
  url: "https://swforrest.github.io/deepSSF/"
12
12
  license: MIT
13
- version: 0.1.0
13
+ version: 0.3.1
@@ -0,0 +1,237 @@
1
+ # Balancing the habitat and movement components of the deepSSF loss
2
+
3
+ A design record for the training changes introduced after 0.3.0. It explains why
4
+ the habitat sub-network appeared not to learn, why the fix is not a loss weight,
5
+ and what the `stages` / `monitor="both"` machinery is for.
6
+
7
+ ## The symptom
8
+
9
+ In a joint model trained on feral pig GPS data, the movement component of the
10
+ validation loss fell by roughly 1.2 nats over a run while the habitat component
11
+ moved by about 0.05 and sometimes drifted upward. On a 75×75 window a uniform
12
+ surface scores `log(75 × 75) = 8.635` nats; the habitat head finished at ≈ 8.25,
13
+ i.e. it had extracted about 0.4 nats of structure in total. Scoring the observed
14
+ next step on held-out data told the same story: mean habitat probability 0.000206
15
+ against a uniform 1/5625 = 0.000178, versus a mean movement probability of 0.054.
16
+
17
+ The natural reading is that the two components are summed into one loss and the
18
+ larger one drowns out the smaller, so the fix must be a weight on the habitat
19
+ term. That reading is wrong in an instructive way.
20
+
21
+ ## What the loss actually is
22
+
23
+ `ConvJointModel` returns a `[B, H, W, 2]` stack in which **both** slices are
24
+ already log-normalised over the window — the habitat head subtracts its own
25
+ `logsumexp` (`Conv2d_block_spatial.forward`), and so does the movement head
26
+ (`Params_to_Grid_Block.forward`). `negativeLogLikeLoss` then sums them and
27
+ renormalises the sum:
28
+
29
+ ```python
30
+ pred_prod = hab_surface + move_surface
31
+ pred_prod = pred_prod - torch.logsumexp(pred_prod, dim=(1, 2), keepdim=True)
32
+ nll = -pred_prod[batch_idx, py2, px2]
33
+ ```
34
+
35
+ The `habitat_loss` and `movement_loss` it also returns are **diagnostics**:
36
+ `train_loop` and `test_loop` discard them (`total_loss, _, _ = loss_fn(...)`), and
37
+ only the epoch summary in `fit` reports them. There is no weighted sum anywhere,
38
+ and therefore no λ to turn.
39
+
40
+ Because both surfaces arrive normalised, the total decomposes exactly:
41
+
42
+ ```
43
+ total = habitat_nll + movement_nll + logZ, logZ = logsumexp(hab + mov) ≤ 0
44
+ ```
45
+
46
+ So the *magnitudes* really are dominated by movement. But the gradient tells a
47
+ different story. Differentiating the total with respect to the habitat
48
+ parameters gives
49
+
50
+ ```
51
+ ∂total/∂θ_hab = -∇hab(y) + E_{p_combined}[∇hab]
52
+ ```
53
+
54
+ — the standard conditional-logistic form: observed minus its expectation under
55
+ the combined surface. That is exactly the right step-selection gradient, with the
56
+ movement kernel playing the role of the availability distribution. **The habitat
57
+ head was never starved of gradient.** Adam normalises by gradient RMS on top of
58
+ that, so the raw scale difference between the components does not translate into
59
+ a proportional difference in step size either.
60
+
61
+ ### Why weighting the habitat term would be wrong
62
+
63
+ The obvious intervention — minimise `total + λ · habitat_nll` — has a specific
64
+ failure mode. `habitat_nll` is the NLL of the habitat surface *on its own*, over
65
+ the whole window, and it is minimised by putting probability mass where animals
66
+ actually end up, which is overwhelmingly near the centre pixel. A habitat head
67
+ trained against it learns a distance-decay bump centred on the previous location:
68
+ it absorbs the movement structure instead of describing resource selection. The
69
+ same objection applies to `negativeLogLikeLoss(freeze_movement=True)`, which drops
70
+ the movement surface from the combined loss entirely — despite its name, it is not
71
+ a way to hold movement fixed while habitat trains. **Do not use it for that.**
72
+
73
+ ## Where the asymmetry actually bites: model selection
74
+
75
+ The damage is downstream of the gradient. Before this change, both
76
+ `ReduceLROnPlateau` schedulers and `EarlyStopping` stepped on the combined
77
+ validation loss. Since that loss moves ~1.2 nats on movement and ~0.05 on
78
+ habitat, habitat's genuine improvement sits *inside* movement's epoch-to-epoch
79
+ noise band. The consequences:
80
+
81
+ - The run early-stops on movement's schedule. Habitat is still improving, but the
82
+ combined loss has stopped moving by more than noise, so the patience counter
83
+ climbs and training ends.
84
+ - The habitat learning rate is cut when the *movement* head plateaus.
85
+
86
+ Neither is fixed by changing the objective. Both are fixed by changing what is
87
+ monitored.
88
+
89
+ ## Two bugs found alongside it
90
+
91
+ Neither is about loss balance, but no balancing scheme could be evaluated while
92
+ they stood.
93
+
94
+ **`conv_movement` belonged to no optimiser.** `make_optimisers` registered only
95
+ `fcn_movement_all` and `conv_habitat`. The movement CNN trunk received gradients
96
+ on every `backward()` but was never zeroed and never stepped, so its `.grad`
97
+ accumulated monotonically across the entire run. Two consequences: the trunk
98
+ stayed at its random initialisation forever, so the movement head was reading
99
+ fixed random features; and once the accumulating buffer overflowed,
100
+ `_grads_are_finite(model)` — which iterates over *every* parameter — began
101
+ returning `False`, at which point **every** subsequent update was silently
102
+ skipped, habitat's included, with only a `non-finite gradients at batch N`
103
+ warning to show for it.
104
+
105
+ **`skip_epoch0_training` disabled training.** The flag had no epoch awareness: it
106
+ was a per-call switch, and `fit` never passed it, so whatever its default was
107
+ applied to every epoch. With the default at `True`, the entire run executed
108
+ forward-only under `torch.set_grad_enabled(False)`. It also measured a
109
+ *training-set* baseline, when the number worth having is a validation one.
110
+
111
+ ## The changes
112
+
113
+ ### 1. Correct parameter ownership
114
+
115
+ `make_optimisers` now puts `conv_movement` and `fcn_movement_all` in the movement
116
+ optimiser as two param groups. `train_loop` zeroes gradients with
117
+ `model.zero_grad(set_to_none=True)` rather than per-optimiser, so no parameter can
118
+ fall outside the reset regardless of how the optimisers are configured.
119
+
120
+ The manual stash-and-restore of habitat gradients that used to bracket the two
121
+ optimiser steps has been deleted. The two optimisers own disjoint parameter sets
122
+ and `Adam.step()` only reads `.grad` for its own parameters, so clearing habitat
123
+ gradients before the movement step and putting them back afterwards was a no-op.
124
+
125
+ A regression test asserts that every parameter in `model.parameters()` is owned by
126
+ exactly one optimiser.
127
+
128
+ ### 2. Per-head monitoring
129
+
130
+ Each `ReduceLROnPlateau` now steps on its own component of the validation loss,
131
+ and only while that component is training — stepping a frozen head's scheduler
132
+ would cut its learning rate for a lack of improvement it was never given the
133
+ chance to make.
134
+
135
+ `EarlyStopping` gains `monitor="both"`, which keeps a separate patience budget per
136
+ component and stops only once *every active* component has plateaued. Checkpointing
137
+ is deliberately unchanged: it still keys on the combined validation loss, because
138
+ the joint likelihood remains the correct model-selection criterion. Only the
139
+ stopping rule changes. `reset()` clears the counters at stage boundaries while
140
+ leaving the best score and saved checkpoint intact.
141
+
142
+ Note that with `delta=0.0` an exactly-unchanged loss counts as an improvement.
143
+ This is pre-existing behaviour, retained; real losses fluctuate, so a genuine
144
+ plateau still accumulates patience.
145
+
146
+ ### 3. Staged (coordinate) training
147
+
148
+ `fit(stages=[...])` runs the sub-networks in turn:
149
+
150
+ ```python
151
+ stages=[
152
+ {"epochs": 10, "train": ("movement",)},
153
+ {"epochs": 40, "train": ("habitat",)},
154
+ {"epochs": 20, "train": ("habitat", "movement")},
155
+ ]
156
+ ```
157
+
158
+ Movement goes first so that a sensible availability kernel is in place before
159
+ habitat is fitted conditional on it. Habitat then runs to its own convergence on
160
+ its own early-stopping clock. A joint stage settles both.
161
+
162
+ A frozen branch is frozen through `set_trainable` (`requires_grad=False`) *and* by
163
+ withholding its optimiser. Crucially, **its surface stays in the likelihood as a
164
+ fixed offset** — only its weights stop moving. That is what makes this coordinate
165
+ ascent on the true joint objective rather than a reweighting of it, and it is the
166
+ difference between this mechanism and `freeze_movement=True`.
167
+
168
+ `reset_optimiser_state` (default `True`) clears the Adam moments of a branch when
169
+ it becomes active in a new stage, so a head frozen for forty epochs does not
170
+ resume on momentum pointing in a long-outdated direction.
171
+
172
+ Early stopping ends the current *stage* and advances to the next; for the final
173
+ stage that ends the run, so the behaviour is uniform.
174
+
175
+ ### 4. A pre-training baseline
176
+
177
+ `skip_epoch0_training` is removed. `fit` instead runs one validation pass before
178
+ the first epoch and records it at index 0 of every history list, with
179
+ `train_losses[0] = nan` and `stage[0] = -1`. An untrained habitat surface is
180
+ uniform, so the baseline should land near `log(H × W)`; the gap between that and
181
+ the final `val_habitat_losses` is a direct, unambiguous read on how much the
182
+ habitat head actually learned. `history` also gains a `stage` key recording which
183
+ stage each epoch belonged to.
184
+
185
+ ## Verifying it on a run
186
+
187
+ - The baseline line should print before epoch 1 with `hab ≈ log(H × W)`.
188
+ - No `non-finite gradients at batch N` warnings should appear — those were the
189
+ signature of the accumulating `conv_movement` gradients.
190
+ - In a movement-only stage `val_habitat_losses` must be exactly flat, and in a
191
+ habitat-only stage `val_movement_losses` must be exactly flat. If either moves,
192
+ the freeze is not working.
193
+ - `val_habitat_losses` should fall materially below `log(H × W)` and flatten
194
+ *before* the run ends, rather than the run ending first.
195
+
196
+ ### 5. Correct covariate scaling
197
+
198
+ Found while investigating the flat habitat surface and fixed alongside it, since
199
+ it feeds the habitat CNN directly. `load_environmental_layers` divided by the
200
+ maximum rather than the range:
201
+
202
+ ```python
203
+ data = (data - lo) / hi # only correct when lo == 0
204
+ ```
205
+
206
+ The failure is sign-dependent, which is why it went unnoticed:
207
+
208
+ | layer range | old result | correct |
209
+ |---|---|---|
210
+ | `lo=0, hi=100` | `[0, 1]` | `[0, 1]` — right by accident |
211
+ | `lo=-10000, hi=10000` (S2 indices ×10 000) | `[0, 2]` | `[0, 1]` |
212
+ | `lo=-100, hi=-10` (all negative) | `[0, -9]` — negative *and* axis-flipped | `[0, 1]` |
213
+ | `hi = 0` | `ZeroDivisionError` | `[0, 1]` |
214
+
215
+ The axis flip is the serious one: it reverses the sign of everything the habitat
216
+ CNN learns from that layer. The denominator is now `hi - lo`, with a constant
217
+ layer mapping to a flat 0 rather than dividing by zero.
218
+
219
+ Out-of-window padding stays at `-1.0`. With inputs correctly in `[0, 1]` that is
220
+ one full data range below the minimum, so padding remains cleanly distinguishable
221
+ from real values — which is what `image_trim_pixels` exists to keep out of the
222
+ snapshot visualisations.
223
+
224
+ **Checkpoints trained on the old scaling will not transfer.** The inputs have
225
+ changed; retrain.
226
+
227
+ ## Still open
228
+
229
+ Found while investigating this, not addressed by it.
230
+
231
+ - **Habitat capacity.** Four conv layers at `output_channels=4` is ~27k parameters
232
+ for the entire model, which may simply be too little to express much spatial
233
+ structure. Worth revisiting now that the scaling is right and the training
234
+ dynamics are trustworthy.
235
+ - **Per-band vs joint scaling.** A multi-band TIFF is scaled by its global min and
236
+ max, so relative magnitudes between bands survive. That is right for bands
237
+ sharing a natural range (NDVI/NDWI/NDMI) and wrong for bands in unrelated units.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: deepSSF
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: Deep learning step selection functions for predicting animal movement.
5
5
  Project-URL: Homepage, https://swforrest.github.io/deepSSF/
6
6
  Project-URL: Repository, https://github.com/swforrest/deepssf
@@ -35,6 +35,8 @@ Requires-Dist: twine>=5.0; extra == 'dev'
35
35
  Provides-Extra: examples
36
36
  Requires-Dist: ipykernel>=6.0; extra == 'examples'
37
37
  Requires-Dist: jupyterlab>=4.0; extra == 'examples'
38
+ Provides-Extra: maps
39
+ Requires-Dist: folium>=0.15; extra == 'maps'
38
40
  Description-Content-Type: text/markdown
39
41
 
40
42
  # deepSSF