data-disaggregation 0.11.0__tar.gz → 0.12.0__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.
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/PKG-INFO +14 -14
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/README.md +9 -2
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/data_disaggregation/__init__.py +1 -2
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/data_disaggregation/actions.py +1 -2
- data_disaggregation-0.12.0/data_disaggregation/base.py +263 -0
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/data_disaggregation/ext.py +68 -68
- data_disaggregation-0.12.0/data_disaggregation/utils.py +112 -0
- data_disaggregation-0.12.0/data_disaggregation/vtypes.py +188 -0
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/data_disaggregation.egg-info/PKG-INFO +14 -14
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/data_disaggregation.egg-info/SOURCES.txt +2 -2
- data_disaggregation-0.12.0/data_disaggregation.egg-info/requires.txt +1 -0
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/data_disaggregation.egg-info/top_level.txt +1 -0
- data_disaggregation-0.12.0/pyproject.toml +86 -0
- data_disaggregation-0.11.0/test/test.py → data_disaggregation-0.12.0/tests/test_data_disaggregation.py +29 -77
- data_disaggregation-0.11.0/data_disaggregation/base.py +0 -185
- data_disaggregation-0.11.0/data_disaggregation/utils.py +0 -196
- data_disaggregation-0.11.0/data_disaggregation/vtypes.py +0 -88
- data_disaggregation-0.11.0/data_disaggregation.egg-info/requires.txt +0 -1
- data_disaggregation-0.11.0/setup.py +0 -28
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/LICENSE +0 -0
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/data_disaggregation.egg-info/dependency_links.txt +0 -0
- {data_disaggregation-0.11.0 → data_disaggregation-0.12.0}/setup.cfg +0 -0
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: data-disaggregation
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Author: Christian Winger
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Documentation, https://wingechr.github.io/data-disaggregation
|
|
7
7
|
Project-URL: Source, https://github.com/wingechr/data-disaggregation
|
|
8
|
-
Platform: any
|
|
9
8
|
Classifier: Programming Language :: Python :: 3
|
|
10
9
|
Classifier: License :: OSI Approved :: MIT License
|
|
11
10
|
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: <3.15,>=3.10
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE
|
|
14
|
-
Requires-Dist: pandas
|
|
15
|
-
Dynamic:
|
|
16
|
-
Dynamic: classifier
|
|
17
|
-
Dynamic: description
|
|
18
|
-
Dynamic: description-content-type
|
|
19
|
-
Dynamic: license
|
|
20
|
-
Dynamic: platform
|
|
21
|
-
Dynamic: project-url
|
|
22
|
-
Dynamic: requires-dist
|
|
14
|
+
Requires-Dist: pandas>=2.0
|
|
15
|
+
Dynamic: license-file
|
|
23
16
|
|
|
24
|
-
#
|
|
17
|
+
# DataDisAggregation Package
|
|
25
18
|
|
|
26
|
-
[](
|
|
19
|
+
[](https://github.com/wingechr/data-disaggregation/blob/main/LICENSE)
|
|
27
20
|
[](https://github.com/wingechr/data-disaggregation)
|
|
28
21
|
[](https://pypi.org/project/data-disaggregation/)
|
|
29
22
|
[](https://github.com/wingechr/data-disaggregation/actions/workflows/test.yml)
|
|
30
23
|
[](https://wingechr.github.io/data-disaggregation/)
|
|
31
24
|
|
|
25
|
+
DataDisAggregation is a python package that lets you perform weighted aggregations and
|
|
26
|
+
weighted disaggregations of data.
|
|
27
|
+
|
|
28
|
+
Development was funded as part of the IND-E project.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
32
|
## Install
|
|
33
33
|
|
|
34
34
|
```bash
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DataDisAggregation Package
|
|
2
2
|
|
|
3
|
-
[](
|
|
3
|
+
[](https://github.com/wingechr/data-disaggregation/blob/main/LICENSE)
|
|
4
4
|
[](https://github.com/wingechr/data-disaggregation)
|
|
5
5
|
[](https://pypi.org/project/data-disaggregation/)
|
|
6
6
|
[](https://github.com/wingechr/data-disaggregation/actions/workflows/test.yml)
|
|
7
7
|
[](https://wingechr.github.io/data-disaggregation/)
|
|
8
8
|
|
|
9
|
+
DataDisAggregation is a python package that lets you perform weighted aggregations and
|
|
10
|
+
weighted disaggregations of data.
|
|
11
|
+
|
|
12
|
+
Development was funded as part of the IND-E project.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
9
16
|
## Install
|
|
10
17
|
|
|
11
18
|
```bash
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""base code without dependencies.
|
|
2
|
+
|
|
3
|
+
* a Variable is a (dict like) mapping from a Domain -> Value from a given Range
|
|
4
|
+
* a Transformation maps a Variable to a a new Variable in a different Domain
|
|
5
|
+
* Domains can be have multiple, nested dimensions
|
|
6
|
+
|
|
7
|
+
* DomainMap is a Variable that maps (Domain1 x Domain2) to a numerical value (size)
|
|
8
|
+
* DomainSize is a Variable that maps a Domain to a numerical value (size)
|
|
9
|
+
this is only needed for Variables of type MetricExtVarType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
Algorithm
|
|
13
|
+
|
|
14
|
+
* We want to map variable of U(Dom1) (of type T) to V(Dom2) (will also be of Type T)
|
|
15
|
+
* Inputs:
|
|
16
|
+
* U(Dom1) and T
|
|
17
|
+
* Dom1Size(Dom1)
|
|
18
|
+
* Dom2Size(Dom2)
|
|
19
|
+
* Dom1Dom2Map(Dom1 x Dom2)
|
|
20
|
+
* Output
|
|
21
|
+
* V(Dom2) of type T
|
|
22
|
+
* Steps
|
|
23
|
+
* start with Dom1Dom2Map, join U (this will replicate values!)
|
|
24
|
+
* IF T==MetricExtVarType:
|
|
25
|
+
* join Dom1Size and Dom2Size and rescale U: U' = U / Dom1Size * Dom2Size
|
|
26
|
+
* GROUP BY Dom2 and use the respective aggregation functions of the type,
|
|
27
|
+
with U being the value and Dom1Dom2Map being the weight
|
|
28
|
+
* Optional Steps
|
|
29
|
+
* if Type is numeric but limited to int: round values
|
|
30
|
+
* if specified: gapfill missing values from Dom2 with na or a suitable default
|
|
31
|
+
* if specified, a threshold for sum(weights)/size(dom2) can be set (usually 0.5)
|
|
32
|
+
to drop elements from output
|
|
33
|
+
|
|
34
|
+
Helper to create the mapping
|
|
35
|
+
* Given a (multidim) input domain and a (multidim) output domain
|
|
36
|
+
* and a weight mapping over a (arbitrary) weight domain:
|
|
37
|
+
* organize into a unique list by shared dims: dims_in, dims_out
|
|
38
|
+
=> [dims_in_only] + [dims_shared] + [dims_out_only]
|
|
39
|
+
* weightdin MUST be a subset of this, but MAY have fewers
|
|
40
|
+
|
|
41
|
+
* Steps:
|
|
42
|
+
* create super domain of cross product of all of those
|
|
43
|
+
* join weights
|
|
44
|
+
* create index pairs for result
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations # Series[...] for older python/pandas
|
|
49
|
+
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
import pandas as pd
|
|
53
|
+
from pandas import DataFrame, Index, MultiIndex, Series
|
|
54
|
+
|
|
55
|
+
from .utils import SeriesDict, SeriesFrame, as_series
|
|
56
|
+
from .vtypes import VariableType
|
|
57
|
+
|
|
58
|
+
_NA_DIM_KEY = "__NA__"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_NA_DIM_KEY(for_index: Index) -> Any:
|
|
62
|
+
sample = for_index[0]
|
|
63
|
+
if isinstance(sample, tuple):
|
|
64
|
+
return tuple([_NA_DIM_KEY] * len(sample))
|
|
65
|
+
return _NA_DIM_KEY
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _assert_index_unique_no_na(index: Index, name: str):
|
|
69
|
+
if not index.is_unique:
|
|
70
|
+
raise Exception(f"index not unique in {name}: {set(index.duplicated())}")
|
|
71
|
+
for level in range(index.nlevels):
|
|
72
|
+
if any(index.get_level_values(level).isna()):
|
|
73
|
+
raise Exception(f"index contains NA in {name}")
|
|
74
|
+
if get_NA_DIM_KEY(index) in index:
|
|
75
|
+
raise Exception(f"index contains {get_NA_DIM_KEY(index)} in {name}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _assert_all_gt0(data: SeriesFrame, name: str):
|
|
79
|
+
if not all(data > 0):
|
|
80
|
+
raise Exception(f"Not all values are > 0 in {name}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _assert_all_ge0(data: SeriesFrame, name: str):
|
|
84
|
+
if not all(data >= 0):
|
|
85
|
+
raise Exception(f"Not all values are >= 0 in {name}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _assert_index_subset(index: Index, ref_index: Index, name: str):
|
|
89
|
+
err = set(index) - set(ref_index)
|
|
90
|
+
if err:
|
|
91
|
+
raise Exception(f"Unexepcted, additional elements in {name}: {err}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_initialize_weights(
|
|
95
|
+
df_weight_map: DataFrame,
|
|
96
|
+
ds_weights_from: Series[float],
|
|
97
|
+
ds_weights_to: Series[float],
|
|
98
|
+
) -> tuple[DataFrame, Series, Series]:
|
|
99
|
+
# convert weights to frame, rows = from, cols = to, realign
|
|
100
|
+
df_weight_map = df_weight_map.reindex(
|
|
101
|
+
index=ds_weights_from.index, columns=ds_weights_to.index
|
|
102
|
+
).fillna(0)
|
|
103
|
+
|
|
104
|
+
# all indices must be unique and not NA
|
|
105
|
+
_assert_index_unique_no_na(df_weight_map.index, "weights (source)")
|
|
106
|
+
_assert_index_unique_no_na(df_weight_map.columns, "weights (target)")
|
|
107
|
+
_assert_index_unique_no_na(ds_weights_from.index, "weights (sum source)")
|
|
108
|
+
_assert_index_unique_no_na(ds_weights_to.index, "weights (sum target)")
|
|
109
|
+
_assert_all_gt0(ds_weights_from, "weights (sum source)")
|
|
110
|
+
_assert_all_gt0(ds_weights_to, "weights (sum target)")
|
|
111
|
+
_assert_all_ge0(df_weight_map, "weights")
|
|
112
|
+
# weights have to be in ds_weights_from / ds_weights_to sums
|
|
113
|
+
_assert_index_subset(df_weight_map.index, ds_weights_from.index, "weights (source)")
|
|
114
|
+
_assert_index_subset(df_weight_map.columns, ds_weights_to.index, "weights (target)")
|
|
115
|
+
|
|
116
|
+
# differences of sum weights and weigh sums
|
|
117
|
+
ds_weights_from_rest = ds_weights_from - df_weight_map.sum(axis=1)
|
|
118
|
+
_assert_all_ge0(ds_weights_from_rest, "col weights for NA")
|
|
119
|
+
ds_weights_to_rest = ds_weights_to - df_weight_map.sum(axis=0)
|
|
120
|
+
_assert_all_ge0(ds_weights_to_rest, "row weights for NA")
|
|
121
|
+
|
|
122
|
+
# add additional row for NA in source
|
|
123
|
+
df_weight_map = pd.concat(
|
|
124
|
+
[
|
|
125
|
+
df_weight_map,
|
|
126
|
+
ds_weights_to_rest.to_frame().T.set_axis(
|
|
127
|
+
[get_NA_DIM_KEY(df_weight_map.index)]
|
|
128
|
+
),
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
ds_weights_from_rest = pd.concat(
|
|
132
|
+
[
|
|
133
|
+
ds_weights_from_rest,
|
|
134
|
+
Series(0, index=[get_NA_DIM_KEY(ds_weights_from_rest.index)]),
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# add NA output col (temp)
|
|
139
|
+
df_weight_map_w_na_column = pd.concat(
|
|
140
|
+
[
|
|
141
|
+
df_weight_map,
|
|
142
|
+
ds_weights_from_rest.rename(
|
|
143
|
+
get_NA_DIM_KEY(df_weight_map.columns) # type: ignore (yes, NA should be col identifier)
|
|
144
|
+
),
|
|
145
|
+
],
|
|
146
|
+
axis=1,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# df_weight_map.columns = ds_weights_to.index
|
|
150
|
+
df_weight_map.columns.names = ds_weights_to.index.names
|
|
151
|
+
|
|
152
|
+
ds_weights_from = df_weight_map_w_na_column.sum(axis=1)
|
|
153
|
+
ds_weights_to = df_weight_map.sum(axis=0)
|
|
154
|
+
|
|
155
|
+
return df_weight_map, ds_weights_from, ds_weights_to
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Transformer:
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
vtype: type[VariableType],
|
|
162
|
+
df_weight_map: DataFrame,
|
|
163
|
+
ds_weights_from: Series | None = None,
|
|
164
|
+
ds_weights_to: Series | None = None,
|
|
165
|
+
weight_rel_threshold: float = 0.0,
|
|
166
|
+
):
|
|
167
|
+
ds_weights_from = (
|
|
168
|
+
df_weight_map.sum(axis=1) if ds_weights_from is None else ds_weights_from
|
|
169
|
+
)
|
|
170
|
+
ds_weights_to = (
|
|
171
|
+
df_weight_map.sum(axis=0) if ds_weights_to is None else ds_weights_to
|
|
172
|
+
)
|
|
173
|
+
df_weight_map, ds_weights_from, ds_weights_to = _check_initialize_weights(
|
|
174
|
+
df_weight_map, ds_weights_from, ds_weights_to
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
self.df_weight_map: DataFrame = df_weight_map
|
|
178
|
+
self.ds_weights_from: Series = ds_weights_from
|
|
179
|
+
self.ds_weights_to: Series = ds_weights_to
|
|
180
|
+
self.vtype: type[VariableType] = vtype
|
|
181
|
+
self.weight_rel_threshold: float = weight_rel_threshold
|
|
182
|
+
|
|
183
|
+
def __call__(self, ds_data: Series) -> Series:
|
|
184
|
+
_assert_index_unique_no_na(ds_data.index, str(ds_data.name))
|
|
185
|
+
return (
|
|
186
|
+
self.vtype.transform(
|
|
187
|
+
ds_data=ds_data,
|
|
188
|
+
df_weight_map=self.df_weight_map,
|
|
189
|
+
ds_weights_from=self.ds_weights_from,
|
|
190
|
+
# ds_weights_to=self.ds_weights_to,
|
|
191
|
+
weight_rel_threshold=self.weight_rel_threshold,
|
|
192
|
+
)
|
|
193
|
+
.rename(ds_data.name) # type:ignore
|
|
194
|
+
.rename_axis(index=self.df_weight_map.columns.names)
|
|
195
|
+
) # type:ignore
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def transform(
|
|
199
|
+
vtype: type[VariableType],
|
|
200
|
+
data: SeriesDict,
|
|
201
|
+
weight_map: SeriesDict,
|
|
202
|
+
weights_from: SeriesDict | None = None,
|
|
203
|
+
weights_to: SeriesDict | None = None,
|
|
204
|
+
weight_rel_threshold: float = 0.0,
|
|
205
|
+
) -> SeriesDict:
|
|
206
|
+
"""(dis-)aggregate data.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
vtype: VariableType
|
|
211
|
+
Variable type of input data, determines the aggregation method.
|
|
212
|
+
data: Mapping[F, V]
|
|
213
|
+
Input data: mapping (usually dict) from any keys (any hashable) to values.
|
|
214
|
+
weight_map: Mapping[Tuple[F, T], float]
|
|
215
|
+
weights for combinations of input and output elements (must be positive).
|
|
216
|
+
Keys must tuples from input/output key pairs.
|
|
217
|
+
weights_from: Mapping[F, float]
|
|
218
|
+
optional weights of input elements (must be positive).
|
|
219
|
+
If not specified, this will be calculated as a sum from `weight_map`.
|
|
220
|
+
weights_to: Mapping[T, float]
|
|
221
|
+
optional weights of output elements (must be positive).
|
|
222
|
+
If not specified, this will be calculated as a sum from `weight_map`.
|
|
223
|
+
weight_rel_threshold: float
|
|
224
|
+
optional value between 0 and 1: all mappings are dropped
|
|
225
|
+
if the sum of input weights / output weight is smaller than this threshold.
|
|
226
|
+
For example, you may want to set it to 0.5 for geographical mappings with
|
|
227
|
+
extensive data.
|
|
228
|
+
|
|
229
|
+
Returns
|
|
230
|
+
-------
|
|
231
|
+
: Mapping[T, V]
|
|
232
|
+
output data as a mapping from output keys (any hashable) to values.
|
|
233
|
+
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
ds_data = as_series(data)
|
|
237
|
+
# harmonize NaN/None/NA and drop from data
|
|
238
|
+
ds_data = ds_data.convert_dtypes().dropna() # normalize and drop na
|
|
239
|
+
|
|
240
|
+
ds_weight_map = as_series(weight_map)
|
|
241
|
+
df_weight_map = ds_weight_map.unstack(level=1)
|
|
242
|
+
# fix problem with multiindex
|
|
243
|
+
if isinstance(ds_data.index, MultiIndex) and not isinstance(
|
|
244
|
+
df_weight_map.index, MultiIndex
|
|
245
|
+
):
|
|
246
|
+
df_weight_map.index = pd.MultiIndex.from_tuples(df_weight_map.index)
|
|
247
|
+
|
|
248
|
+
ds_weights_from = None if weights_from is None else as_series(weights_from)
|
|
249
|
+
ds_weights_to = None if weights_to is None else as_series(weights_to)
|
|
250
|
+
|
|
251
|
+
transformer = Transformer(
|
|
252
|
+
vtype=vtype,
|
|
253
|
+
df_weight_map=df_weight_map,
|
|
254
|
+
ds_weights_from=ds_weights_from,
|
|
255
|
+
ds_weights_to=ds_weights_to,
|
|
256
|
+
weight_rel_threshold=weight_rel_threshold,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
ds_result = transformer(ds_data)
|
|
260
|
+
|
|
261
|
+
result = type(data)(ds_result)
|
|
262
|
+
|
|
263
|
+
return result
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
"""extended functions, especially for pandas Series
|
|
2
|
-
"""
|
|
1
|
+
"""extended functions, especially for pandas Series"""
|
|
3
2
|
|
|
4
|
-
from typing import
|
|
3
|
+
from typing import cast
|
|
5
4
|
|
|
6
5
|
import numpy as np
|
|
7
6
|
from pandas import DataFrame, Index, MultiIndex, Series
|
|
8
7
|
|
|
9
|
-
from .base import
|
|
10
|
-
from .utils import is_scalar
|
|
11
|
-
from .vtypes import
|
|
8
|
+
from .base import Transformer
|
|
9
|
+
from .utils import SCALAR_DIM_NAME, SCALAR_INDEX_KEY, SeriesFrame, is_scalar
|
|
10
|
+
from .vtypes import VariableType
|
|
12
11
|
|
|
13
12
|
IDX_SCALAR = MultiIndex.from_product([Index([SCALAR_INDEX_KEY], name=SCALAR_DIM_NAME)])
|
|
14
13
|
COL_WEIGHT = "__WEIGHT__"
|
|
@@ -16,17 +15,17 @@ COL_FROM = "__FROM__"
|
|
|
16
15
|
COL_TO = "__TO__"
|
|
17
16
|
|
|
18
17
|
|
|
19
|
-
def harmonize_input_data(data:
|
|
20
|
-
"""
|
|
21
|
-
return DataFrame with MultiIndex
|
|
22
|
-
"""
|
|
18
|
+
def harmonize_input_data(data: DataFrame | Series | float) -> DataFrame:
|
|
19
|
+
"""return DataFrame with MultiIndex"""
|
|
23
20
|
if is_scalar(data):
|
|
24
|
-
|
|
21
|
+
df = DataFrame({SCALAR_INDEX_KEY: data}, index=IDX_SCALAR)
|
|
25
22
|
elif isinstance(data, Series):
|
|
26
|
-
|
|
23
|
+
df = data.to_frame()
|
|
24
|
+
elif not isinstance(data, DataFrame):
|
|
25
|
+
raise TypeError(type(data))
|
|
27
26
|
# ensure multiindex
|
|
28
|
-
|
|
29
|
-
return
|
|
27
|
+
df = ensure_multiindex(df)
|
|
28
|
+
return df
|
|
30
29
|
|
|
31
30
|
|
|
32
31
|
def as_multiindex(index: Index) -> MultiIndex:
|
|
@@ -35,7 +34,7 @@ def as_multiindex(index: Index) -> MultiIndex:
|
|
|
35
34
|
return index
|
|
36
35
|
|
|
37
36
|
|
|
38
|
-
def ensure_multiindex(item:
|
|
37
|
+
def ensure_multiindex(item: SeriesFrame) -> SeriesFrame:
|
|
39
38
|
if not isinstance(item.index, MultiIndex):
|
|
40
39
|
index = as_multiindex(item.index)
|
|
41
40
|
item = item.copy() # TODO: can we replace index without copy?
|
|
@@ -44,51 +43,56 @@ def ensure_multiindex(item: Union[DataFrame, Series]) -> Union[DataFrame, Series
|
|
|
44
43
|
|
|
45
44
|
|
|
46
45
|
def as_list_of_series_w_multiindex(
|
|
47
|
-
items:
|
|
48
|
-
) ->
|
|
46
|
+
items: Index | Series | list[Index | Series],
|
|
47
|
+
) -> list[Series]:
|
|
49
48
|
# make sure we have a list/tuple
|
|
50
|
-
if not isinstance(items, (list, tuple))
|
|
51
|
-
items = [items]
|
|
49
|
+
list_items = [items] if not isinstance(items, (list, tuple)) else items
|
|
52
50
|
# make sure we have series:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
list_items = [
|
|
52
|
+
it if isinstance(it, Series) else Series(1, index=it) for it in list_items
|
|
53
|
+
]
|
|
54
|
+
list_items = [ensure_multiindex(it) for it in list_items]
|
|
55
|
+
return list_items
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
def merge_indices(
|
|
58
|
+
def merge_indices(
|
|
59
|
+
items: list[Series] | list[Index] | list[Index | Series],
|
|
60
|
+
) -> MultiIndex:
|
|
59
61
|
"""Create product of unions of indices"""
|
|
60
62
|
# ensure items are multiindices
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
list_items = [it if isinstance(it, Index) else it.index for it in items]
|
|
64
|
+
list_items = [as_multiindex(it) for it in list_items]
|
|
63
65
|
indices = {}
|
|
64
|
-
for it in
|
|
66
|
+
for it in list_items:
|
|
65
67
|
for idx in it.levels:
|
|
66
68
|
if idx.name not in indices:
|
|
67
69
|
indices[idx.name] = idx
|
|
68
70
|
else:
|
|
69
71
|
indices[idx.name] = indices[idx.name].union(idx)
|
|
70
|
-
return MultiIndex.from_product(indices.values())
|
|
72
|
+
return MultiIndex.from_product(list(indices.values()))
|
|
71
73
|
|
|
72
74
|
|
|
73
75
|
def combine_weights(
|
|
74
|
-
weights:
|
|
76
|
+
weights: Index | Series | list[Index | Series],
|
|
75
77
|
) -> Series:
|
|
76
78
|
"""multiply all weights series
|
|
79
|
+
|
|
77
80
|
* join on overlapping columns (or all if none
|
|
78
81
|
* if index and not series: use value 1
|
|
79
82
|
|
|
80
83
|
Returns:
|
|
81
84
|
Series with MultiIndex
|
|
85
|
+
|
|
82
86
|
"""
|
|
83
87
|
# make sure we have series:
|
|
84
|
-
|
|
88
|
+
weights_list = as_list_of_series_w_multiindex(weights)
|
|
85
89
|
|
|
86
90
|
# merge indices
|
|
87
|
-
idx = merge_indices(
|
|
91
|
+
idx = merge_indices(weights_list)
|
|
88
92
|
|
|
89
93
|
# multiply all and drop nan
|
|
90
94
|
result = Series(1, index=idx)
|
|
91
|
-
for w in
|
|
95
|
+
for w in weights_list:
|
|
92
96
|
# IMPORTANT: `result *= w` gives a different result, so DONT use it
|
|
93
97
|
result = result * w
|
|
94
98
|
|
|
@@ -121,7 +125,7 @@ def format_result(df, input_is_df, output_is_scalar, output_multiindex):
|
|
|
121
125
|
|
|
122
126
|
def get_idx_out(idx_in: MultiIndex, idx_weights: MultiIndex) -> MultiIndex:
|
|
123
127
|
idx_all = merge_indices([idx_in, idx_weights])
|
|
124
|
-
idx_levels = dict(zip(idx_all.names, idx_all.levels))
|
|
128
|
+
idx_levels = dict(zip(idx_all.names, idx_all.levels, strict=False))
|
|
125
129
|
|
|
126
130
|
idx_names_only_in = set(idx_in.names) - set(idx_weights.names)
|
|
127
131
|
idx_names_only_weights = set(idx_weights.names) - set(idx_in.names)
|
|
@@ -158,8 +162,8 @@ def remap_series_to_frame(s: Series, idx: MultiIndex, colname: str) -> DataFrame
|
|
|
158
162
|
def create_weight_map(
|
|
159
163
|
ds_weights: Series, idx_in: MultiIndex, idx_out: MultiIndex
|
|
160
164
|
) -> Series:
|
|
161
|
-
"""
|
|
162
|
-
|
|
165
|
+
"""Returns weight Series
|
|
166
|
+
|
|
163
167
|
Index is 2 dimensional (F, T), each part is a tuple from idx_in, idx_out
|
|
164
168
|
for overlapping levels: left == right
|
|
165
169
|
|
|
@@ -167,8 +171,8 @@ def create_weight_map(
|
|
|
167
171
|
idx_all = merge_indices([idx_in, idx_out])
|
|
168
172
|
# expand index (TODO: check if weights are dropped??)
|
|
169
173
|
df = remap_series_to_frame(ds_weights, idx_all, COL_WEIGHT)
|
|
170
|
-
df[COL_FROM] = list(zip(*[df[n] for n in idx_in.names]))
|
|
171
|
-
df[COL_TO] = list(zip(*[df[n] for n in idx_out.names]))
|
|
174
|
+
df[COL_FROM] = list(zip(*[df[n] for n in idx_in.names], strict=False))
|
|
175
|
+
df[COL_TO] = list(zip(*[df[n] for n in idx_out.names], strict=False))
|
|
172
176
|
|
|
173
177
|
# filter: TODO, faster way?
|
|
174
178
|
df = df.loc[df[COL_FROM].isin(idx_in)]
|
|
@@ -180,7 +184,7 @@ def create_weight_map(
|
|
|
180
184
|
return ds_weight_map
|
|
181
185
|
|
|
182
186
|
|
|
183
|
-
def validate_multiindex(item:
|
|
187
|
+
def validate_multiindex(item: Index | Series | DataFrame):
|
|
184
188
|
if isinstance(item, (Series, DataFrame)):
|
|
185
189
|
item = item.index
|
|
186
190
|
assert isinstance(item, MultiIndex)
|
|
@@ -190,13 +194,13 @@ def validate_multiindex(item: Union[Index, Series, DataFrame]):
|
|
|
190
194
|
|
|
191
195
|
|
|
192
196
|
def transform_pandas(
|
|
193
|
-
vtype: VariableType,
|
|
194
|
-
data:
|
|
195
|
-
weights:
|
|
196
|
-
dim_in:
|
|
197
|
-
dim_out:
|
|
197
|
+
vtype: type[VariableType],
|
|
198
|
+
data: DataFrame | Series | float,
|
|
199
|
+
weights: Index | Series | list[Index | Series],
|
|
200
|
+
dim_in: Index | Series | None = None,
|
|
201
|
+
dim_out: Index | Series | None = None,
|
|
198
202
|
validate: bool = True,
|
|
199
|
-
) ->
|
|
203
|
+
) -> DataFrame | Series | float:
|
|
200
204
|
"""(dis-)aggregate data (pandas).
|
|
201
205
|
|
|
202
206
|
Parameters
|
|
@@ -244,9 +248,12 @@ def transform_pandas(
|
|
|
244
248
|
ds_size_in = ensure_multiindex(dim_in)
|
|
245
249
|
idx_in = ds_size_in.index
|
|
246
250
|
|
|
251
|
+
idx_in = cast(MultiIndex, idx_in)
|
|
252
|
+
|
|
247
253
|
# determine output_index
|
|
248
254
|
if dim_out is None:
|
|
249
|
-
|
|
255
|
+
idx_weights = cast(MultiIndex, ds_weights.index)
|
|
256
|
+
idx_out = get_idx_out(idx_in, idx_weights)
|
|
250
257
|
ds_size_out = None
|
|
251
258
|
elif isinstance(dim_out, Index):
|
|
252
259
|
idx_out = as_multiindex(dim_out)
|
|
@@ -254,6 +261,7 @@ def transform_pandas(
|
|
|
254
261
|
else:
|
|
255
262
|
ds_size_out = ensure_multiindex(dim_out)
|
|
256
263
|
idx_out = ds_size_out.index
|
|
264
|
+
idx_out = cast(MultiIndex, idx_out)
|
|
257
265
|
|
|
258
266
|
if validate:
|
|
259
267
|
validate_multiindex(idx_in)
|
|
@@ -263,9 +271,7 @@ def transform_pandas(
|
|
|
263
271
|
ds_weight_map = create_weight_map(ds_weights, idx_in, idx_out)
|
|
264
272
|
|
|
265
273
|
if ds_size_in is None:
|
|
266
|
-
ds_size_in = (
|
|
267
|
-
ds_weight_map.reset_index().groupby(COL_FROM).sum(COL_WEIGHT)[COL_WEIGHT]
|
|
268
|
-
)
|
|
274
|
+
ds_size_in = ds_weight_map.reset_index().groupby(COL_FROM).sum()[COL_WEIGHT]
|
|
269
275
|
# fix index
|
|
270
276
|
ds_size_in.index = MultiIndex.from_tuples(
|
|
271
277
|
ds_size_in.index.values, names=idx_in.names
|
|
@@ -273,9 +279,7 @@ def transform_pandas(
|
|
|
273
279
|
ds_size_in = ds_size_in.loc[ds_size_in > 0]
|
|
274
280
|
|
|
275
281
|
if ds_size_out is None:
|
|
276
|
-
ds_size_out = (
|
|
277
|
-
ds_weight_map.reset_index().groupby(COL_TO).sum(COL_WEIGHT)[COL_WEIGHT]
|
|
278
|
-
)
|
|
282
|
+
ds_size_out = ds_weight_map.reset_index().groupby(COL_TO).sum()[COL_WEIGHT]
|
|
279
283
|
# fix index
|
|
280
284
|
ds_size_out.index = MultiIndex.from_tuples(
|
|
281
285
|
ds_size_out.index.values, names=idx_out.names
|
|
@@ -291,26 +295,20 @@ def transform_pandas(
|
|
|
291
295
|
idx_in = ds_size_in.index
|
|
292
296
|
idx_out = ds_size_out.index
|
|
293
297
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
for name in df_data.columns:
|
|
297
|
-
s_col = df_data[name]
|
|
298
|
-
s_col = s_col.dropna()
|
|
299
|
-
|
|
300
|
-
res_col = transform(
|
|
301
|
-
vtype=vtype,
|
|
302
|
-
data=s_col,
|
|
303
|
-
weight_map=ds_weight_map,
|
|
304
|
-
weights_from=ds_size_in,
|
|
305
|
-
weights_to=ds_size_out,
|
|
306
|
-
weight_rel_threshold=0.0,
|
|
307
|
-
validate=False, # we do validation in pandas
|
|
308
|
-
)
|
|
298
|
+
df_weight_map = ds_weight_map.unstack(level=1)
|
|
299
|
+
df_weight_map.columns = idx_out
|
|
309
300
|
|
|
310
|
-
|
|
311
|
-
|
|
301
|
+
transformer = Transformer(
|
|
302
|
+
vtype=vtype,
|
|
303
|
+
df_weight_map=df_weight_map,
|
|
304
|
+
ds_weights_from=ds_size_in,
|
|
305
|
+
ds_weights_to=ds_size_out,
|
|
306
|
+
weight_rel_threshold=0,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
df_result = df_data.apply(transformer) # FIXME: why is this different
|
|
312
310
|
|
|
313
|
-
|
|
311
|
+
result = format_result(
|
|
314
312
|
df_result,
|
|
315
313
|
input_is_df=isinstance(data, DataFrame),
|
|
316
314
|
output_is_scalar=idx_out.equals(IDX_SCALAR),
|
|
@@ -318,3 +316,5 @@ def transform_pandas(
|
|
|
318
316
|
True if dim_out is None else isinstance(idx_out, MultiIndex)
|
|
319
317
|
),
|
|
320
318
|
)
|
|
319
|
+
|
|
320
|
+
return result # type:ignore
|