Skip to content

Commit a80da84

Browse files
committed
return mask, not saving
1 parent ed5fe61 commit a80da84

3 files changed

Lines changed: 145 additions & 80 deletions

File tree

‎doc/source/stats.md‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,38 @@ In this example, we will create different altitude classes from a chosen interva
105105
Once these bins are created, we reapply them and compute the mean, minimum, and maximum values of the same raster
106106
for each sub-interval. It is also possible to use a reference other than the raster itself for the group_by.
107107

108+
A dictionary containing the masks that have been created during the computation will also be returned by the function.
109+
Using geoUtils functions makes it very easy to visualise them.
110+
108111
```{code-cell} ipython3
109112
from geoutils.stats import grouped_stats
113+
import math
114+
import matplotlib.pyplot as plt
110115
111116
group_by = {"rast": rast}
112117
bins = {"rast": [400, 1000, 2000, 3000, np.inf]}
113118
to_aggregate = {"rast": rast}
114119
statistics = ["mean", "min", "max"]
115120
116-
df = grouped_stats.grouped_stats(group_by, bins, to_aggregate, statistics)
117-
df
121+
df, masks = grouped_stats.grouped_stats(group_by, bins, to_aggregate, statistics)
122+
123+
groups = list(masks["groupby_rast"].keys())
124+
n = len(groups)
125+
126+
ncols = 3
127+
nrows = math.ceil(n / ncols)
128+
129+
fig, axes = plt.subplots(nrows, ncols, figsize=(5*ncols, 5*nrows))
130+
axes = axes.flatten()
131+
132+
for ax, group in zip(axes, groups):
133+
masks["groupby_rast"][group].plot(ax=ax)
134+
ax.set_title(group)
135+
136+
for ax in axes[n:]:
137+
ax.axis("off")
138+
139+
plt.tight_layout()
118140
```
119141

120142
```{warning}
@@ -135,10 +157,14 @@ from geoutils.stats import grouped_stats
135157
group_by = {"rast": rast}
136158
elev_mask = rast > 2000
137159
bins = {"rast": elev_mask}
160+
161+
elev_mask.plot()
162+
plt.show()
163+
138164
to_aggregate = {"rast": rast}
139165
statistics = ["mean", "min", "max"]
140166
141-
df = grouped_stats.grouped_stats(group_by, bins, to_aggregate, statistics, save_masks="")
167+
df, _ = grouped_stats.grouped_stats(group_by, bins, to_aggregate, statistics)
142168
df
143169
```
144170

‎geoutils/stats/grouped_stats.py‎

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from __future__ import annotations
2525

2626
import logging
27-
from pathlib import Path
2827
from typing import Any
2928

3029
import numpy as np
@@ -87,23 +86,20 @@ def grouped_stats(
8786
bins: dict[str, RasterType | list[int] | object],
8887
aggregated_vars: dict[str, NDArrayNum],
8988
statistics: list[str],
90-
save_csv: str | Path | None = None,
91-
save_masks: str | Path | None = None,
92-
) -> pd.DataFrame:
89+
) -> tuple[Any, dict[str, dict[str, NDArrayNum] | None]]:
9390
"""
9491
Get statistics grouped (=binned) by other variables, whether categorical or continuous.
9592
:param groupby_vars: Dictionary containing Raster to group by.
9693
:param bins: Bins to use. Can be a list of interval, binary mask or segmentation map.
9794
:param aggregated_vars: Values to group, can be a dictionary .
9895
:param statistics: List or dict of statistics to compute, e.g. ["mean", "std"].
99-
:param save_csv: Path to save CSV file.
100-
:param save_masks: Path to save masks.
10196
"""
10297

10398
# Check that groupby_vars and bins as the same values
10499
if set(groupby_vars) != set(bins):
105100
raise ValueError("One bins/mask/segmentation entry per input array required.")
106101

102+
# Load bins
107103
new_bins = {}
108104
for key, value in bins.items():
109105
if isinstance(value, str):
@@ -114,72 +110,69 @@ def grouped_stats(
114110

115111
crs, shape, transform = init_binnings_attributes(next(iter(groupby_vars.values())))
116112

117-
# panda dataframe works on arrays
113+
# Flatten arrays for pandas
118114
groupby_vars_array = from_raster_to_flattened(groupby_vars)
119115
aggregated_vars_arrays = from_raster_to_flattened(aggregated_vars)
120116

121-
# Init panda data frame
122117
df = pd.DataFrame({**groupby_vars_array, **aggregated_vars_arrays})
118+
123119
groupby_keys = []
124-
# Analyze type of binnings
120+
returned_masks = {}
121+
125122
for groupby_key in groupby_vars.keys():
126123
group_col = f"groupby_{groupby_key}"
127-
128124
bins_array = new_bins[groupby_key]
129125

130-
# is interval
126+
if isinstance(bins[groupby_key], (str, Raster)):
127+
returned_masks[group_col] = None
128+
else:
129+
returned_masks[group_col] = {}
130+
131+
# ---------- Interval bins ----------
131132
if is_interval(bins_array):
132133
cut = pd.cut(df[groupby_key], bins=bins_array)
133134
df[group_col] = cut
134135

135-
if save_masks and not isinstance(bins[groupby_key], str):
136+
if returned_masks[group_col] is not None:
136137
for interval in cut.cat.categories:
137138
mask_flat = (cut == interval).to_numpy()
138-
mask = mask_flat.reshape(shape)
139-
outdir = Path(save_masks) / group_col
140-
outdir.mkdir(parents=True, exist_ok=True)
141-
fname = f"{groupby_key}_{interval.left}_{interval.right}.tif"
142-
Raster.from_array(mask.reshape(shape), transform=transform, crs=crs).save(outdir / fname)
139+
mask_to_raster = Raster.from_array(mask_flat.reshape(shape), transform, crs)
140+
141+
returned_masks[group_col][f"{groupby_key}_{interval.left}_{interval.right}"] = mask_to_raster
143142

143+
# ---------- Raster (mask or segmentation) ----------
144144
elif isinstance(bins_array, Raster):
145145
data = np.asarray(bins_array.data.data).flatten()
146+
146147
if len(data) != len(df):
147148
raise ValueError(f"Mask has invalid length {len(data)}, expected {len(df)}")
148-
# binary mask
149+
150+
# Binary mask
149151
if bins_array.is_mask:
150152
df[group_col] = pd.Categorical(data, categories=[False, True])
151-
if save_masks and not isinstance(bins[groupby_key], str):
152-
mask = data.reshape(shape).astype("uint8")
153-
fname = f"{groupby_key}_mask.tif"
154-
outdir = Path(save_masks) / group_col
155-
outdir.mkdir(parents=True, exist_ok=True)
156-
Raster.from_array(mask.reshape(shape), transform=transform, crs=crs).save(outdir / fname)
157-
# segmentation
153+
154+
if returned_masks[group_col] is not None:
155+
mask_to_raster = Raster.from_array(data.reshape(shape).astype("uint8"), transform, crs)
156+
157+
returned_masks[group_col][f"{groupby_key}_mask"] = mask_to_raster
158+
159+
# Segmentation
158160
else:
159161
df[group_col] = pd.Categorical(data)
160162

161-
if save_masks and not isinstance(bins[groupby_key], str):
162-
labels = np.unique(data[~np.isnan(data)])
163-
for lbl in labels:
164-
mask_flat = data == lbl
165-
mask = mask_flat.reshape(shape)
163+
if returned_masks[group_col] is not None:
164+
segm_names = np.unique(data[~np.isnan(data)])
165+
for segm_name in segm_names:
166+
mask_to_raster = Raster.from_array((data == segm_name).reshape(shape), transform, crs)
167+
168+
returned_masks[group_col][f"{groupby_key}_seg_{int(segm_name)}"] = mask_to_raster
166169

167-
fname = f"{groupby_key}_seg_{int(lbl)}.tif"
168-
outdir = Path(save_masks) / group_col
169-
outdir.mkdir(parents=True, exist_ok=True)
170-
Raster.from_array(mask.reshape(shape), transform=transform, crs=crs).save(outdir / fname)
171170
else:
172171
raise NotImplementedError("This type of bins does not yet work")
173172

174-
# Compute the stats
175173
groupby_keys.append(group_col)
176174

177-
# Use aggregation directly on categorical columns (fast)
178-
result = df.groupby(groupby_keys)[list(aggregated_vars.keys())].agg(statistics)
179-
180-
if save_csv:
181-
csv_file = Path(save_csv) / "grouped_stats.csv"
182-
csv_file.parent.mkdir(parents=True, exist_ok=True)
183-
result.reset_index().to_csv(csv_file, index=False)
175+
# Compute statistics
176+
result = df.groupby(groupby_keys, observed=True)[list(aggregated_vars.keys())].agg(statistics)
184177

185-
return result
178+
return result, returned_masks

‎tests/test_stats/test_grouped_stats.py‎

Lines changed: 80 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from pathlib import PosixPath
21
from typing import Any, Dict, List
32

43
import numpy as np
@@ -91,7 +90,7 @@ def test_is_interval() -> None:
9190
([0, 5, -2, 40, np.inf], ValueError, "Values must be strictly increasing."),
9291
],
9392
) # type: ignore
94-
def test_is_interval_with_error(interval, type_error, match) -> None:
93+
def test_is_interval_with_error(interval: List[Any], type_error: TypeError, match: str) -> None:
9594

9695
with pytest.raises(type_error, match=match):
9796
_ = grouped_stats.is_interval(interval)
@@ -104,28 +103,86 @@ def test_is_interval_with_error(interval, type_error, match) -> None:
104103
({"slope3": [0, 1, 2], "slope4": [0, 1, 2]}), # different keys
105104
],
106105
) # type: ignore
107-
def test_grouped_stats_errors(slope_raster, aggregated_vars, bins_test, statistics) -> None:
106+
def test_grouped_stats_errors(
107+
slope_raster: Raster, aggregated_vars: Dict[str, Any], bins_test: Dict[str, Any], statistics: List[Any]
108+
) -> None:
108109
group_by_test = {"slope1": slope_raster, "slope2": slope_raster}
109110
with pytest.raises(ValueError, match="One bins/mask/segmentation entry per input array required."):
110111
_ = grouped_stats.grouped_stats(group_by_test, bins_test, aggregated_vars, statistics)
111112

112113

113-
def test_grouped_stats_interval(slope_raster: Raster, aggregated_vars: Dict[str, Any], statistics: List[Any]) -> None:
114+
def test_grouped_stats_interval(
115+
slope_raster: Raster, aggregated_vars: Dict[str, Any], statistics: List[Any], elev_raster: Raster
116+
) -> None:
114117
group_by_test = {"slope1": slope_raster}
115118
expected_df = pd.DataFrame(
116119
{
117120
("raster", "mean"): [3, 8, 15.5],
118121
("raster", "min"): [1, 6, 11],
119122
},
120123
index=pd.CategoricalIndex(
121-
pd.IntervalIndex.from_breaks([-0.001, 5.0, 10.0, np.inf], closed="right"),
124+
pd.IntervalIndex.from_breaks([0.0, 5.0, 10.0, np.inf], closed="right"),
122125
ordered=True,
123126
name="groupby_slope1",
124127
),
125128
)
126-
df_test = grouped_stats.grouped_stats(group_by_test, {"slope1": [0, 5, 10, np.inf]}, aggregated_vars, statistics)
129+
130+
crs, _, transform_test = grouped_stats.init_binnings_attributes(elev_raster)
131+
132+
expected_mask = {
133+
"groupby_slope1": {
134+
"slope1_0.0_5.0": Raster.from_array(
135+
np.array(
136+
[
137+
[True, True, True, True, True],
138+
[False, False, False, False, False],
139+
[False, False, False, False, False],
140+
[False, False, False, False, False],
141+
]
142+
),
143+
transform=transform_test,
144+
crs=crs,
145+
nodata=None,
146+
),
147+
"slope1_5.0_10.0": Raster.from_array(
148+
np.array(
149+
[
150+
[False, False, False, False, False],
151+
[True, True, True, True, True],
152+
[False, False, False, False, False],
153+
[False, False, False, False, False],
154+
]
155+
),
156+
transform=transform_test,
157+
crs=crs,
158+
nodata=None,
159+
),
160+
"slope1_10.0_inf": Raster.from_array(
161+
np.array(
162+
[
163+
[False, False, False, False, False],
164+
[False, False, False, False, False],
165+
[True, True, True, True, True],
166+
[True, True, True, True, True],
167+
]
168+
),
169+
transform=transform_test,
170+
crs=crs,
171+
nodata=None,
172+
),
173+
}
174+
}
175+
176+
df_test, masks_test = grouped_stats.grouped_stats(
177+
group_by_test, {"slope1": [0, 5, 10, np.inf]}, aggregated_vars, statistics
178+
)
127179
assert_frame_equal(df_test, expected_df)
128180

181+
assert all(
182+
expected_mask["groupby_slope1"][group] == masks_test["groupby_slope1"][group] # type: ignore
183+
for group in expected_mask["groupby_slope1"]
184+
)
185+
129186

130187
def test_grouped_stats_mask(
131188
slope_raster: Raster, aggregated_vars: Dict[str, Any], mask_raster: Raster, statistics: List[Any] # type: ignore
@@ -141,62 +198,51 @@ def test_grouped_stats_mask(
141198
),
142199
)
143200

144-
df_test = grouped_stats.grouped_stats(group_by_test, {"slope1": mask_raster}, aggregated_vars, statistics)
201+
df_test, masks_test = grouped_stats.grouped_stats(
202+
group_by_test, {"slope1": mask_raster}, aggregated_vars, statistics
203+
)
145204
assert_frame_equal(df_test, expected_df)
146205

206+
expected_mask = {"groupby_slope1": None}
207+
208+
assert expected_mask == masks_test
209+
147210

148211
def test_grouped_stats_segm(
149212
slope_raster: Raster, aggregated_vars: Dict[str, Any], segm_raster: Raster, statistics: List[Any]
150213
) -> None:
151-
group_by_test = {"slope1": slope_raster}
214+
group_by_test = {"segm": slope_raster}
152215

153216
expected_df = pd.DataFrame(
154217
{
155218
("raster", "mean"): [8.125000, 11.285714, 13.200000],
156219
("raster", "min"): [1, 3, 4],
157220
},
158221
index=pd.CategoricalIndex(
159-
[1, 2, 3], categories=[1, 2, 3], ordered=False, dtype="category", name="groupby_slope1"
222+
[1, 2, 3], categories=[1, 2, 3], ordered=False, dtype="category", name="groupby_segm"
160223
),
161224
)
162-
df_test = grouped_stats.grouped_stats(group_by_test, {"slope1": segm_raster}, aggregated_vars, statistics)
225+
df_test, masks_test = grouped_stats.grouped_stats(group_by_test, {"segm": segm_raster}, aggregated_vars, statistics)
163226
assert_frame_equal(df_test, expected_df)
164227

228+
expected_mask = {"groupby_segm": None}
229+
230+
assert expected_mask == masks_test
231+
165232

166233
def test_intersection_stats(
167234
slope_raster: Raster, aggregated_vars: Dict[str, Any], segm_raster: Raster, statistics: List[Any]
168235
) -> None:
169236
group_by_test = {"slope1": slope_raster, "slope2": slope_raster}
170237
bins_test = {"slope1": [0, 5, 10, np.inf], "slope2": segm_raster}
171238

172-
df_test = grouped_stats.grouped_stats(group_by_test, bins_test, aggregated_vars, statistics)
239+
df_test, _ = grouped_stats.grouped_stats(group_by_test, bins_test, aggregated_vars, statistics)
173240

174241
assert df_test.loc[(pd.Interval(10.0, np.inf), 3), ("raster", "mean")] == (15 + 19 + 20) / 3
175242
assert df_test.loc[(pd.Interval(10.0, np.inf), 3), ("raster", "min")] == 15
176243

177-
assert df_test.loc[(pd.Interval(-0.001, 5), 1), ("raster", "mean")] == (1 + 2 + 5) / 3
178-
assert df_test.loc[(pd.Interval(-0.001, 5), 1), ("raster", "min")] == 1
244+
assert df_test.loc[(pd.Interval(0.0, 5), 1), ("raster", "mean")] == (1 + 2 + 5) / 3
245+
assert df_test.loc[(pd.Interval(0.0, 5), 1), ("raster", "min")] == 1
179246

180247
assert df_test.loc[(pd.Interval(10.0, np.inf), 2), ("raster", "mean")] == (16 + 12 + 17 + 18) / 4
181248
assert df_test.loc[(pd.Interval(10.0, np.inf), 2), ("raster", "min")] == 12
182-
183-
184-
def test_savings_csv_and_tiff(
185-
slope_raster: Raster,
186-
aggregated_vars: Dict[str, Any],
187-
segm_raster: Raster,
188-
statistics: List[Any],
189-
tmp_path: PosixPath,
190-
) -> None:
191-
group_by_test = {"slope1": slope_raster, "slope2": slope_raster}
192-
bins_test = {"slope1": [0, 5, 10, np.inf], "slope2": segm_raster}
193-
194-
_ = grouped_stats.grouped_stats(
195-
group_by_test, bins_test, aggregated_vars, statistics, save_csv=tmp_path, save_masks=tmp_path
196-
)
197-
198-
for interval in ["0_5.0", "10.0_inf", "5.0_10.0"]:
199-
assert tmp_path / "groupbyslope1" / f"slope1_{interval}.tif"
200-
for seg in [1, 2, 3]:
201-
assert tmp_path / "groupbyslope2" / f"slope2_{seg}.tif"
202-
assert tmp_path / "grouped_stats.csv"

0 commit comments

Comments
 (0)