owid-datautils 0.6.1__py3-none-any.whl
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.
- owid/datautils/__init__.py +3 -0
- owid/datautils/checks.py +1 -0
- owid/datautils/common.py +44 -0
- owid/datautils/dataframes.py +739 -0
- owid/datautils/decorators.py +91 -0
- owid/datautils/format/__init__.py +7 -0
- owid/datautils/format/numbers.py +304 -0
- owid/datautils/google/__init__.py +7 -0
- owid/datautils/google/api.py +136 -0
- owid/datautils/google/config.py +117 -0
- owid/datautils/google/sheets.py +134 -0
- owid/datautils/io/__init__.py +14 -0
- owid/datautils/io/archive.py +84 -0
- owid/datautils/io/df.py +163 -0
- owid/datautils/io/json.py +78 -0
- owid/datautils/ui.py +32 -0
- owid/datautils/web.py +106 -0
- owid_datautils-0.6.1.dist-info/METADATA +20 -0
- owid_datautils-0.6.1.dist-info/RECORD +21 -0
- owid_datautils-0.6.1.dist-info/WHEEL +4 -0
- owid_datautils-0.6.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
"""Objects related to pandas dataframes."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from pandas.api.types import union_categoricals
|
|
9
|
+
|
|
10
|
+
from owid.datautils.common import ExceptionFromDocstring, warn_on_list_of_entities
|
|
11
|
+
from owid.datautils.io.df import to_file as to_file_
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Backwards compatibility
|
|
15
|
+
def to_file(*args: Any, **kwargs: Any) -> None:
|
|
16
|
+
"""Save a dataframe in any format.
|
|
17
|
+
|
|
18
|
+
Will be deprecated. Use owid.datautils.io.df.to_file instead.
|
|
19
|
+
"""
|
|
20
|
+
warnings.warn(
|
|
21
|
+
"Call to deprecated class to_file (This function will be removed in the next"
|
|
22
|
+
" minor update, use owid.datautils.io.df_to_file instead.)",
|
|
23
|
+
category=DeprecationWarning,
|
|
24
|
+
stacklevel=2,
|
|
25
|
+
)
|
|
26
|
+
to_file_(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_index(df: pd.DataFrame) -> bool:
|
|
30
|
+
"""Return True if a dataframe has an index, and False if it does not (i.e. if it has a dummy index).
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
df : pd.DataFrame
|
|
35
|
+
Dataframe whose index will be checked.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
df_has_index : bool
|
|
40
|
+
True if dataframe has a non-dummy (single- or multi-) index.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
# Dataframes always have an attribute index.names, which is a frozen list.
|
|
44
|
+
# If the dataframe has no set index (i.e. if it has a dummy index), that list contains only [None].
|
|
45
|
+
# In any other case, the frozen list contains one or more elements different than None.
|
|
46
|
+
df_has_index = True if df.index.names[0] is not None else False
|
|
47
|
+
|
|
48
|
+
return df_has_index
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DataFramesHaveDifferentLengths(ExceptionFromDocstring):
|
|
52
|
+
"""Dataframes cannot be compared because they have different number of rows."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ObjectsAreNotDataframes(ExceptionFromDocstring):
|
|
56
|
+
"""Given objects are not dataframes."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compare(
|
|
60
|
+
df1: pd.DataFrame,
|
|
61
|
+
df2: pd.DataFrame,
|
|
62
|
+
columns: Optional[List[str]] = None,
|
|
63
|
+
absolute_tolerance: float = 1e-8,
|
|
64
|
+
relative_tolerance: float = 1e-8,
|
|
65
|
+
) -> pd.DataFrame:
|
|
66
|
+
"""Compare two dataframes element by element to see if they are equal.
|
|
67
|
+
|
|
68
|
+
It assumes that nans are all identical, and allows for certain absolute and relative tolerances for the comparison
|
|
69
|
+
of floats.
|
|
70
|
+
|
|
71
|
+
NOTE: Dataframes must have the same number of rows to be able to compare them.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
df1 : pd.DataFrame
|
|
76
|
+
First dataframe.
|
|
77
|
+
df2 : pd.DataFrame
|
|
78
|
+
Second dataframe.
|
|
79
|
+
columns : list or None
|
|
80
|
+
List of columns to compare (they both must exist in both dataframes). If None, common columns will be compared.
|
|
81
|
+
absolute_tolerance : float
|
|
82
|
+
Absolute tolerance to assume in the comparison of each cell in the dataframes. A value a of an element in df1 is
|
|
83
|
+
considered equal to the corresponding element b at the same position in df2, if:
|
|
84
|
+
abs(a - b) <= absolute_tolerance
|
|
85
|
+
relative_tolerance : float
|
|
86
|
+
Relative tolerance to assume in the comparison of each cell in the dataframes. A value a of an element in df1 is
|
|
87
|
+
considered equal to the corresponding element b at the same position in df2, if:
|
|
88
|
+
abs(a - b) / abs(b) <= relative_tolerance
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
compared : pd.DataFrame
|
|
93
|
+
Dataframe of booleans, with as many rows as df1 and df2, and as many columns as specified by `columns` argument
|
|
94
|
+
(or as many common columns between df1 and df2, if `columns` is None). The (i, j) element is True if df1 and f2
|
|
95
|
+
have the same value (for the given tolerances) at that same position.
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
# Ensure dataframes can be compared.
|
|
99
|
+
if (not isinstance(df1, pd.DataFrame)) or (not isinstance(df2, pd.DataFrame)):
|
|
100
|
+
raise ObjectsAreNotDataframes
|
|
101
|
+
if len(df1) != len(df2):
|
|
102
|
+
raise DataFramesHaveDifferentLengths
|
|
103
|
+
|
|
104
|
+
# If columns are not specified, assume common columns.
|
|
105
|
+
if columns is None:
|
|
106
|
+
columns = sorted(set(df1.columns) & set(df2.columns))
|
|
107
|
+
|
|
108
|
+
# Compare, column by column, the elements of the two dataframes.
|
|
109
|
+
compared = pd.DataFrame()
|
|
110
|
+
for col in columns:
|
|
111
|
+
if (df1[col].dtype in (object, "category", "string")) or (df2[col].dtype in (object, "category", "string")):
|
|
112
|
+
# Apply a direct comparison for strings or categories
|
|
113
|
+
compared_row = df1[col].values == df2[col].values
|
|
114
|
+
else:
|
|
115
|
+
# For numeric data, consider them equal within certain absolute and relative tolerances.
|
|
116
|
+
compared_row = np.isclose(
|
|
117
|
+
df1[col].values, # type: ignore
|
|
118
|
+
df2[col].values, # type: ignore
|
|
119
|
+
atol=absolute_tolerance,
|
|
120
|
+
rtol=relative_tolerance,
|
|
121
|
+
)
|
|
122
|
+
# Treat nans as equal.
|
|
123
|
+
compared_row[pd.isnull(df1[col].values) & pd.isnull(df2[col].values)] = True # type: ignore
|
|
124
|
+
compared[col] = compared_row
|
|
125
|
+
|
|
126
|
+
return compared
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def are_equal(
|
|
130
|
+
df1: pd.DataFrame,
|
|
131
|
+
df2: pd.DataFrame,
|
|
132
|
+
absolute_tolerance: float = 1e-8,
|
|
133
|
+
relative_tolerance: float = 1e-8,
|
|
134
|
+
verbose: bool = True,
|
|
135
|
+
) -> Tuple[bool, pd.DataFrame]:
|
|
136
|
+
"""Check whether two dataframes are equal.
|
|
137
|
+
|
|
138
|
+
It assumes that all nans are identical, and compares floats by means of certain absolute and relative tolerances.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
df1 : pd.DataFrame
|
|
143
|
+
First dataframe.
|
|
144
|
+
df2 : pd.DataFrame
|
|
145
|
+
Second dataframe.
|
|
146
|
+
absolute_tolerance : float
|
|
147
|
+
Absolute tolerance to assume in the comparison of each cell in the dataframes. A value a of an element in df1 is
|
|
148
|
+
considered equal to the corresponding element b at the same position in df2, if:
|
|
149
|
+
abs(a - b) <= absolute_tolerance
|
|
150
|
+
relative_tolerance : float
|
|
151
|
+
Relative tolerance to assume in the comparison of each cell in the dataframes. A value a of an element in df1 is
|
|
152
|
+
considered equal to the corresponding element b at the same position in df2, if:
|
|
153
|
+
abs(a - b) / abs(b) <= relative_tolerance
|
|
154
|
+
verbose : bool
|
|
155
|
+
True to print a summary of the comparison of the two dataframes.
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
are_equal : bool
|
|
160
|
+
True if the two dataframes are equal (given the conditions explained above).
|
|
161
|
+
compared : pd.DataFrame
|
|
162
|
+
Dataframe with the same shape as df1 and df2 (if they have the same shape) that is True on each element where
|
|
163
|
+
both dataframes have equal values. If dataframes have different shapes, compared will be empty.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
# Initialise flag that is True only if both dataframes are equal.
|
|
167
|
+
equal = True
|
|
168
|
+
# Initialise flag that is True if dataframes can be compared cell by cell.
|
|
169
|
+
can_be_compared = True
|
|
170
|
+
# Initialise string of messages, which will optionally be printed.
|
|
171
|
+
summary = ""
|
|
172
|
+
|
|
173
|
+
# Check if all columns in df2 are in df1.
|
|
174
|
+
missing_in_df1 = sorted(set(df2.columns) - set(df1.columns))
|
|
175
|
+
if len(missing_in_df1):
|
|
176
|
+
summary += f"\n* {len(missing_in_df1)} columns in df2 missing in df1.\n"
|
|
177
|
+
summary += "\n".join([f" * {col}" for col in missing_in_df1])
|
|
178
|
+
equal = False
|
|
179
|
+
|
|
180
|
+
# Check if all columns in df1 are in df2.
|
|
181
|
+
missing_in_df2 = sorted(set(df1.columns) - set(df2.columns))
|
|
182
|
+
if len(missing_in_df2):
|
|
183
|
+
summary += f"\n* {len(missing_in_df2)} columns in df1 missing in df2.\n"
|
|
184
|
+
summary += "\n".join([f" * {col}" for col in missing_in_df2])
|
|
185
|
+
equal = False
|
|
186
|
+
|
|
187
|
+
# Check if dataframes have the same number of rows.
|
|
188
|
+
if len(df1) != len(df2):
|
|
189
|
+
summary += f"\n* {len(df1)} rows in df1 and {len(df2)} rows in df2."
|
|
190
|
+
equal = False
|
|
191
|
+
can_be_compared = False
|
|
192
|
+
|
|
193
|
+
# Check for differences in column names or types.
|
|
194
|
+
common_columns = sorted(set(df1.columns) & set(df2.columns))
|
|
195
|
+
all_columns = sorted(set(df1.columns) | set(df2.columns))
|
|
196
|
+
if common_columns == all_columns:
|
|
197
|
+
if df1.columns.tolist() != df2.columns.tolist():
|
|
198
|
+
summary += "\n* Columns are sorted differently.\n"
|
|
199
|
+
equal = False
|
|
200
|
+
for col in common_columns:
|
|
201
|
+
if df1[col].dtype != df2[col].dtype:
|
|
202
|
+
summary += (
|
|
203
|
+
f" * Column {col} is of type {df1[col].dtype} for df1, but type" f" {df2[col].dtype} for df2."
|
|
204
|
+
)
|
|
205
|
+
equal = False
|
|
206
|
+
else:
|
|
207
|
+
summary += f"\n* Only {len(common_columns)} common columns out of" f" {len(all_columns)} distinct columns."
|
|
208
|
+
equal = False
|
|
209
|
+
|
|
210
|
+
if not can_be_compared:
|
|
211
|
+
# Dataframes cannot be compared.
|
|
212
|
+
compared = pd.DataFrame()
|
|
213
|
+
equal = False
|
|
214
|
+
else:
|
|
215
|
+
# Check if indexes are equal.
|
|
216
|
+
if (df1.index != df2.index).any():
|
|
217
|
+
summary += "\n* Dataframes have different indexes (consider resetting indexes of" " input dataframes)."
|
|
218
|
+
equal = False
|
|
219
|
+
|
|
220
|
+
# Dataframes can be compared cell by cell (two nans on the same cell are considered equal).
|
|
221
|
+
compared = compare(
|
|
222
|
+
df1,
|
|
223
|
+
df2,
|
|
224
|
+
columns=common_columns,
|
|
225
|
+
absolute_tolerance=absolute_tolerance,
|
|
226
|
+
relative_tolerance=relative_tolerance,
|
|
227
|
+
)
|
|
228
|
+
all_values_equal = compared.all().all() # type: ignore
|
|
229
|
+
if not all_values_equal:
|
|
230
|
+
summary += "\n* Values differ by more than the given absolute and relative" " tolerances."
|
|
231
|
+
|
|
232
|
+
# Dataframes are equal only if all previous checks have passed.
|
|
233
|
+
equal = equal & all_values_equal
|
|
234
|
+
|
|
235
|
+
if equal:
|
|
236
|
+
summary += (
|
|
237
|
+
"Dataframes are identical (within absolute tolerance of"
|
|
238
|
+
f" {absolute_tolerance} and relative tolerance of {relative_tolerance})."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if verbose:
|
|
242
|
+
# Optionally print the summary of the comparison.
|
|
243
|
+
print(summary)
|
|
244
|
+
|
|
245
|
+
return equal, compared
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _calculate_weighted_mean(
|
|
249
|
+
group_data: pd.DataFrame,
|
|
250
|
+
value_col: str,
|
|
251
|
+
weight_col: str,
|
|
252
|
+
num_allowed_nans: int | None = None,
|
|
253
|
+
frac_allowed_nans: float | None = None,
|
|
254
|
+
min_num_values: int | None = None,
|
|
255
|
+
) -> float:
|
|
256
|
+
"""Calculate weighted mean for a group, applying NaN handling rules."""
|
|
257
|
+
values = group_data[value_col]
|
|
258
|
+
weights = group_data[weight_col]
|
|
259
|
+
|
|
260
|
+
# Apply same NaN handling logic as regular aggregations
|
|
261
|
+
total_count = len(values)
|
|
262
|
+
mask = ~(pd.isna(values) | pd.isna(weights) | (weights == 0))
|
|
263
|
+
valid_values = values[mask]
|
|
264
|
+
valid_weights = weights[mask]
|
|
265
|
+
valid_count = len(valid_values)
|
|
266
|
+
nan_count = total_count - valid_count
|
|
267
|
+
|
|
268
|
+
# Apply NaN handling rules to follow the same logic as for the non-weighted aggregations (defined in groupby_agg).
|
|
269
|
+
if (num_allowed_nans is not None) and (nan_count > num_allowed_nans):
|
|
270
|
+
return np.nan
|
|
271
|
+
if (frac_allowed_nans is not None) and (total_count > 0) and (nan_count / total_count > frac_allowed_nans):
|
|
272
|
+
return np.nan
|
|
273
|
+
if (min_num_values is not None) and (valid_count < min_num_values) and (nan_count > 0):
|
|
274
|
+
return np.nan
|
|
275
|
+
if len(valid_values) == 0:
|
|
276
|
+
return np.nan
|
|
277
|
+
return float(np.average(valid_values, weights=valid_weights))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def groupby_agg(
|
|
281
|
+
df: pd.DataFrame,
|
|
282
|
+
groupby_columns: Union[List[str], str],
|
|
283
|
+
aggregations: Optional[Dict[str, Any]] = None,
|
|
284
|
+
num_allowed_nans: Optional[int] = None,
|
|
285
|
+
frac_allowed_nans: Optional[float] = None,
|
|
286
|
+
min_num_values: Optional[int] = None,
|
|
287
|
+
) -> pd.DataFrame:
|
|
288
|
+
"""Group dataframe by certain columns, and aggregate using a certain method, and decide how to handle nans.
|
|
289
|
+
|
|
290
|
+
This function is similar to the usual
|
|
291
|
+
> df.groupby(groupby_columns).agg(aggregations)
|
|
292
|
+
However, pandas by default ignores nans in aggregations. This implies, for example, that
|
|
293
|
+
> df.groupby(groupby_columns).sum()
|
|
294
|
+
will treat nans as zeros, which can be misleading.
|
|
295
|
+
|
|
296
|
+
When both num_allowed_nans, frac_allowed_nans, and min_num_values are None, this function behaves like the default
|
|
297
|
+
pandas groupby().agg() (and nans may be treated as zeros).
|
|
298
|
+
|
|
299
|
+
Otherwise, if any of those parameters is not None, then the following conditions are applied (one after the other):
|
|
300
|
+
|
|
301
|
+
1. If num_allowed_nans is not None, then a group will be nan if the number of nans in that group is larger than
|
|
302
|
+
num_allowed_nans.
|
|
303
|
+
For example, if num_allowed_nans is set to 1, and there are 2 or more nans in a group, the aggregate will be nan.
|
|
304
|
+
|
|
305
|
+
2. If frac_allowed_nans is not None, then a group will be nan if the fraction of nans in that group is larger than
|
|
306
|
+
frac_allowed_nans.
|
|
307
|
+
For example, if frac_allowed_nans is set to 0.2, and the fraction of nans in a group 0.201, the aggregate will be
|
|
308
|
+
nan.
|
|
309
|
+
|
|
310
|
+
3. If min_num_values is not None, then a group will be nan if the number of non-nan values is smaller than
|
|
311
|
+
min_num_values. Note that, for this condition to be relevant, min_num_values must be >= 1.
|
|
312
|
+
For example, if min_num_values is set to 1, and all values in a group are nan, the aggregate will be nan (instead
|
|
313
|
+
of a spurious zero, as it commonly happens).
|
|
314
|
+
|
|
315
|
+
NOTE: This function won't work when using multiple aggregations for the same column (e.g. {'a': ('sum', 'mean')}).
|
|
316
|
+
|
|
317
|
+
Parameters
|
|
318
|
+
----------
|
|
319
|
+
df : pd.DataFrame
|
|
320
|
+
Original dataframe.
|
|
321
|
+
groupby_columns : list or str
|
|
322
|
+
List of columns to group by. It can be given as a string, if it is only one column.
|
|
323
|
+
aggregations : dict or None
|
|
324
|
+
Aggregations to apply to each column in df. If None, 'sum' will be applied to all columns.
|
|
325
|
+
num_allowed_nans : int or None
|
|
326
|
+
Maximum number of nans that are allowed in a group.
|
|
327
|
+
frac_allowed_nans : float or None
|
|
328
|
+
Maximum fraction of nans that are allowed in a group.
|
|
329
|
+
min_num_values : int or None
|
|
330
|
+
Minimum number of non-nan values that a group must have. If fewer values are found, the aggregate will be nan.
|
|
331
|
+
|
|
332
|
+
Returns
|
|
333
|
+
-------
|
|
334
|
+
grouped : pd.DataFrame
|
|
335
|
+
Grouped dataframe after applying aggregations.
|
|
336
|
+
|
|
337
|
+
"""
|
|
338
|
+
if isinstance(groupby_columns, str):
|
|
339
|
+
groupby_columns = [groupby_columns]
|
|
340
|
+
|
|
341
|
+
if aggregations is None:
|
|
342
|
+
columns_to_aggregate = [column for column in df.columns if column not in groupby_columns]
|
|
343
|
+
aggregations = {column: "sum" for column in columns_to_aggregate}
|
|
344
|
+
|
|
345
|
+
# Default groupby arguments, `observed` makes sure the final dataframe
|
|
346
|
+
# does not explode with NaNs
|
|
347
|
+
groupby_kwargs = {
|
|
348
|
+
"dropna": False,
|
|
349
|
+
"observed": True,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# Handle weighted aggregations separately if any are present
|
|
353
|
+
weighted_aggregations = {
|
|
354
|
+
k: v for k, v in aggregations.items() if isinstance(v, str) and v.startswith("mean_weighted_by_")
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if weighted_aggregations:
|
|
358
|
+
# Split out regular aggregations to handle them normally
|
|
359
|
+
regular_aggregations = {
|
|
360
|
+
k: v for k, v in aggregations.items() if not (isinstance(v, str) and v.startswith("mean_weighted_by_"))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# Handle regular aggregations first (if any)
|
|
364
|
+
if regular_aggregations:
|
|
365
|
+
grouped = df.groupby(groupby_columns, **groupby_kwargs).agg(regular_aggregations) # type: ignore
|
|
366
|
+
else:
|
|
367
|
+
# Create empty DataFrame with proper groupby index for weighted-only case
|
|
368
|
+
grouped = (
|
|
369
|
+
df.groupby(groupby_columns, dropna=False, observed=True)
|
|
370
|
+
.size()
|
|
371
|
+
.to_frame("_temp")
|
|
372
|
+
.drop(columns=["_temp"])
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Add weighted mean columns
|
|
376
|
+
for col, agg_func in weighted_aggregations.items():
|
|
377
|
+
weight_col = agg_func.replace("mean_weighted_by_", "")
|
|
378
|
+
if weight_col not in df.columns:
|
|
379
|
+
raise ValueError(f"Weight column '{weight_col}' not found in data")
|
|
380
|
+
|
|
381
|
+
weighted_results = df.groupby(groupby_columns, dropna=False, observed=True).apply(
|
|
382
|
+
lambda group: _calculate_weighted_mean(
|
|
383
|
+
group, col, weight_col, num_allowed_nans, frac_allowed_nans, min_num_values
|
|
384
|
+
),
|
|
385
|
+
include_groups=False,
|
|
386
|
+
)
|
|
387
|
+
grouped[col] = weighted_results # type: ignore
|
|
388
|
+
else:
|
|
389
|
+
# No weighted aggregations; use standard grouping logic
|
|
390
|
+
grouped = df.groupby(groupby_columns, **groupby_kwargs).agg(aggregations) # type: ignore
|
|
391
|
+
|
|
392
|
+
# Calculate a few necessary parameters related to the number of nans and valid elements.
|
|
393
|
+
if (num_allowed_nans is not None) or (frac_allowed_nans is not None) or (min_num_values is not None):
|
|
394
|
+
# Count the number of missing values in each group.
|
|
395
|
+
num_nans_detected = count_missing_in_groups(df, groupby_columns, **groupby_kwargs)
|
|
396
|
+
if (frac_allowed_nans is not None) or (min_num_values is not None):
|
|
397
|
+
# Count number of total elements in each group (counting both nans and non-nan values).
|
|
398
|
+
num_elements = df.groupby(groupby_columns, **groupby_kwargs).size() # type: ignore
|
|
399
|
+
|
|
400
|
+
# Apply conditions sequentially.
|
|
401
|
+
if num_allowed_nans is not None:
|
|
402
|
+
# Make nan any aggregation where there were too many missing values.
|
|
403
|
+
grouped = grouped[num_nans_detected <= num_allowed_nans] # type: ignore
|
|
404
|
+
|
|
405
|
+
if frac_allowed_nans is not None:
|
|
406
|
+
# Make nan any aggregation where there were too many missing values.
|
|
407
|
+
grouped = grouped[num_nans_detected.divide(num_elements, axis="index") <= frac_allowed_nans] # type: ignore
|
|
408
|
+
|
|
409
|
+
if min_num_values is not None:
|
|
410
|
+
# Make nan any aggregation where there were too few valid (non-nan) values.
|
|
411
|
+
# The number of valid values is the number of elements minus the number of nans. So, a priori, what we need is:
|
|
412
|
+
# grouped = grouped[(-num_nans_detected.subtract(num_elements, axis="index") >= min_num_values)]
|
|
413
|
+
# However, if a group has fewer elements than min_num_values, the condition is not fulfilled, and the aggregate
|
|
414
|
+
# is nan. But that is probably not the desired behavior. Instead, if all elements in a group are valid, the
|
|
415
|
+
# aggregate should exist, even if that number of valid values is smaller than min_num_values.
|
|
416
|
+
# Therefore, we impose that either the number of valid values is >= min_num_values, or that there are no nans
|
|
417
|
+
# (and hence all values are valid).
|
|
418
|
+
grouped = grouped[
|
|
419
|
+
(-num_nans_detected.subtract(num_elements, axis="index") >= min_num_values) | (num_nans_detected == 0) # type: ignore
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
return cast(pd.DataFrame, grouped)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def count_missing_in_groups(df: pd.DataFrame, groupby_columns: List[str], **kwargs: Any) -> pd.DataFrame:
|
|
426
|
+
"""Count the number of missing values in each group.
|
|
427
|
+
|
|
428
|
+
Faster version of:
|
|
429
|
+
|
|
430
|
+
>>> num_nans_detected = df.groupby(groupby_columns, **groupby_kwargs).agg(
|
|
431
|
+
lambda x: pd.isnull(x).sum()
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
"""
|
|
435
|
+
nan_columns = [c for c in df.columns if c not in groupby_columns]
|
|
436
|
+
|
|
437
|
+
num_nans_detected = df[nan_columns].isnull().groupby([df[c] for c in groupby_columns], **kwargs).sum()
|
|
438
|
+
|
|
439
|
+
return cast(pd.DataFrame, num_nans_detected)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def multi_merge(dfs: List[pd.DataFrame], on: Union[List[str], str], how: str = "inner") -> pd.DataFrame:
|
|
443
|
+
"""Merge multiple dataframes.
|
|
444
|
+
|
|
445
|
+
This is a helper function when merging more than two dataframes on common columns.
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
dfs : list
|
|
450
|
+
Dataframes to be merged.
|
|
451
|
+
on : list or str
|
|
452
|
+
Column or list of columns on which to merge. These columns must have the same name on all dataframes.
|
|
453
|
+
how : str
|
|
454
|
+
Method to use for merging (with the same options available in pd.merge).
|
|
455
|
+
|
|
456
|
+
Returns
|
|
457
|
+
-------
|
|
458
|
+
merged : pd.DataFrame
|
|
459
|
+
Input dataframes merged.
|
|
460
|
+
|
|
461
|
+
"""
|
|
462
|
+
merged = dfs[0].copy()
|
|
463
|
+
for df in dfs[1:]:
|
|
464
|
+
merged = pd.merge(merged, df, how=how, on=on) # type: ignore
|
|
465
|
+
|
|
466
|
+
return merged
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def map_series(
|
|
470
|
+
series: pd.Series,
|
|
471
|
+
mapping: Dict[Any, Any],
|
|
472
|
+
make_unmapped_values_nan: bool = False,
|
|
473
|
+
warn_on_missing_mappings: bool = False,
|
|
474
|
+
warn_on_unused_mappings: bool = False,
|
|
475
|
+
show_full_warning: bool = False,
|
|
476
|
+
) -> pd.Series:
|
|
477
|
+
"""Map values of a series given a certain mapping.
|
|
478
|
+
|
|
479
|
+
This function does almost the same as
|
|
480
|
+
> series.map(mapping)
|
|
481
|
+
However, map() translates values into nan if those values are not in the mapping, whereas this function allows to
|
|
482
|
+
optionally keep the original values.
|
|
483
|
+
|
|
484
|
+
This function should do the same as
|
|
485
|
+
> series.replace(mapping)
|
|
486
|
+
However .replace() becomes very slow on big dataframes.
|
|
487
|
+
|
|
488
|
+
Parameters
|
|
489
|
+
----------
|
|
490
|
+
series : pd.Series
|
|
491
|
+
Original series to be mapped.
|
|
492
|
+
mapping : dict
|
|
493
|
+
Mapping.
|
|
494
|
+
make_unmapped_values_nan : bool
|
|
495
|
+
If true, values in the series that are not in the mapping will be translated into nan; otherwise, they will keep
|
|
496
|
+
their original values.
|
|
497
|
+
warn_on_missing_mappings : bool
|
|
498
|
+
True to warn if elements in series are missing in mapping.
|
|
499
|
+
warn_on_unused_mappings : bool
|
|
500
|
+
True to warn if the mapping contains values that are not present in the series. False to ignore.
|
|
501
|
+
show_full_warning : bool
|
|
502
|
+
True to print the entire list of unused mappings (only relevant if warn_on_unused_mappings is True).
|
|
503
|
+
|
|
504
|
+
Returns
|
|
505
|
+
-------
|
|
506
|
+
series_mapped : pd.Series
|
|
507
|
+
Mapped series.
|
|
508
|
+
|
|
509
|
+
"""
|
|
510
|
+
# If given category, only map category names and return category type.
|
|
511
|
+
if series.dtype == "category":
|
|
512
|
+
# Remove unused categories in input series.
|
|
513
|
+
series = series.cat.remove_unused_categories()
|
|
514
|
+
|
|
515
|
+
new_categories = map_series(
|
|
516
|
+
pd.Series(series.cat.categories),
|
|
517
|
+
mapping=mapping,
|
|
518
|
+
make_unmapped_values_nan=make_unmapped_values_nan,
|
|
519
|
+
warn_on_missing_mappings=warn_on_missing_mappings,
|
|
520
|
+
warn_on_unused_mappings=warn_on_unused_mappings,
|
|
521
|
+
show_full_warning=show_full_warning,
|
|
522
|
+
)
|
|
523
|
+
category_mapping = dict(zip(series.cat.categories, new_categories))
|
|
524
|
+
return rename_categories(series, category_mapping)
|
|
525
|
+
|
|
526
|
+
# Translate values in series following the mapping.
|
|
527
|
+
series_mapped = series.map(mapping)
|
|
528
|
+
if not make_unmapped_values_nan:
|
|
529
|
+
# Rows that had values that were not in the mapping are now nan.
|
|
530
|
+
# Replace those nans with their original values, except if they were actually meant to be mapped to nan.
|
|
531
|
+
# For example, if {"bad_value": np.nan} was part of the mapping, do not replace those nans back to "bad_value".
|
|
532
|
+
|
|
533
|
+
# if we are setting values from the original series, ensure we have the same dtype
|
|
534
|
+
try:
|
|
535
|
+
series_mapped = series_mapped.astype(series.dtype, copy=False)
|
|
536
|
+
except ValueError:
|
|
537
|
+
# casting NaNs to integer will fail
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
# Detect values in the mapping that were intended to be mapped to nan.
|
|
541
|
+
values_mapped_to_nan = [
|
|
542
|
+
original_value for original_value, target_value in mapping.items() if pd.isnull(target_value)
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
# Make a mask that is True for new nans that need to be replaced back to their original values.
|
|
546
|
+
missing = series_mapped.isnull() & (~series.isin(values_mapped_to_nan))
|
|
547
|
+
if missing.any():
|
|
548
|
+
# Replace those nans by their original values.
|
|
549
|
+
series_mapped.loc[missing] = series[missing] # type: ignore[reportCallIssue]
|
|
550
|
+
|
|
551
|
+
if warn_on_missing_mappings:
|
|
552
|
+
unmapped = set(series) - set(mapping)
|
|
553
|
+
if len(unmapped) > 0:
|
|
554
|
+
warn_on_list_of_entities(
|
|
555
|
+
unmapped,
|
|
556
|
+
f"{len(unmapped)} missing values in mapping.",
|
|
557
|
+
show_list=show_full_warning,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if warn_on_unused_mappings:
|
|
561
|
+
unused = set(mapping) - set(series)
|
|
562
|
+
if len(unused) > 0:
|
|
563
|
+
warn_on_list_of_entities(
|
|
564
|
+
unused,
|
|
565
|
+
f"{len(unused)} unused values in mapping.",
|
|
566
|
+
show_list=show_full_warning,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
return series_mapped
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def rename_categories(series: pd.Series, mapping: Dict[Any, Any]) -> pd.Series:
|
|
573
|
+
"""Alternative to pd.Series.cat.rename_categories which supports non-unique categories.
|
|
574
|
+
|
|
575
|
+
We do that by replacing non-unique categories first and then mapping with unique categories.
|
|
576
|
+
Unused categories are removed during the process. It should be as fast as
|
|
577
|
+
pd.Series.cat.rename_categories if there are no non-unique categories.
|
|
578
|
+
"""
|
|
579
|
+
if series.dtype != "category":
|
|
580
|
+
raise ValueError("Series must be of type category.")
|
|
581
|
+
|
|
582
|
+
series = series.copy()
|
|
583
|
+
|
|
584
|
+
new_mapping: Dict[Any, Any] = {}
|
|
585
|
+
for map_from, map_to in mapping.items():
|
|
586
|
+
# Map nulls right away
|
|
587
|
+
if pd.isnull(map_to):
|
|
588
|
+
series[series == map_from] = np.nan
|
|
589
|
+
|
|
590
|
+
# Non-unique category, replace it first
|
|
591
|
+
elif map_to in new_mapping.values():
|
|
592
|
+
# Find the category that maps to map_to
|
|
593
|
+
series[series == map_from] = [k for k, v in new_mapping.items() if v == map_to][0]
|
|
594
|
+
else:
|
|
595
|
+
new_mapping[map_from] = map_to
|
|
596
|
+
|
|
597
|
+
# NOTE: removing unused categories is necessary because of renaming
|
|
598
|
+
return cast(
|
|
599
|
+
pd.Series,
|
|
600
|
+
series.cat.remove_unused_categories().cat.rename_categories(new_mapping).cat.remove_unused_categories(),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def concatenate(objs: List[pd.DataFrame], **kwargs: Any) -> pd.DataFrame:
|
|
605
|
+
"""Concatenate while preserving categorical columns.
|
|
606
|
+
|
|
607
|
+
Original source code from https://stackoverflow.com/a/57809778/1275818.
|
|
608
|
+
"""
|
|
609
|
+
# Iterate on categorical columns common to all dfs
|
|
610
|
+
for col in set.intersection(*[set(df.select_dtypes(include="category").columns) for df in objs]):
|
|
611
|
+
ignore_order = any([not df[col].cat.ordered for df in objs])
|
|
612
|
+
# Generate the union category across dfs for this column
|
|
613
|
+
uc = union_categoricals([df[col] for df in objs], ignore_order=ignore_order)
|
|
614
|
+
# Change to union category for all dataframes
|
|
615
|
+
for df in objs:
|
|
616
|
+
# df.loc[:, col] = pd.Categorical(df[col].values, categories=uc.categories)
|
|
617
|
+
df[col] = df[col].astype(pd.CategoricalDtype(categories=uc.categories, ordered=uc.ordered))
|
|
618
|
+
|
|
619
|
+
with warnings.catch_warnings():
|
|
620
|
+
warnings.simplefilter(action="ignore", category=FutureWarning)
|
|
621
|
+
return pd.concat(objs, **kwargs)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def apply_on_categoricals(cat_series: List[pd.Series], func: Callable[..., str]) -> pd.Series:
|
|
625
|
+
"""Apply a function on a list of categorical series.
|
|
626
|
+
|
|
627
|
+
This is much faster than converting them to strings first and then applying the function and it prevents memory
|
|
628
|
+
explosion. It uses category codes instead of using values directly and it builds the output categorical mapping
|
|
629
|
+
from codes to strings on the fly.
|
|
630
|
+
|
|
631
|
+
Parameters
|
|
632
|
+
----------
|
|
633
|
+
cat_series :
|
|
634
|
+
List of series with category type.
|
|
635
|
+
func :
|
|
636
|
+
Function taking as many arguments as there are categorical series and returning str.
|
|
637
|
+
"""
|
|
638
|
+
seen = {}
|
|
639
|
+
codes = []
|
|
640
|
+
categories = []
|
|
641
|
+
for cat_codes in zip(*[s.cat.codes for s in cat_series]):
|
|
642
|
+
if cat_codes not in seen:
|
|
643
|
+
# add category
|
|
644
|
+
# -1 is a special code for missing values
|
|
645
|
+
cat_values = [s.cat.categories[code] if code != -1 else np.nan for s, code in zip(cat_series, cat_codes)]
|
|
646
|
+
categories.append(func(*cat_values))
|
|
647
|
+
seen[cat_codes] = len(categories) - 1
|
|
648
|
+
|
|
649
|
+
# use existing category
|
|
650
|
+
codes.append(seen[cat_codes])
|
|
651
|
+
|
|
652
|
+
return cast(pd.Series, pd.Categorical.from_codes(codes, categories=categories))
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def combine_two_overlapping_dataframes(
|
|
656
|
+
df1: pd.DataFrame,
|
|
657
|
+
df2: pd.DataFrame,
|
|
658
|
+
index_columns: Optional[List[str]] = None,
|
|
659
|
+
keep_column_order: bool = False,
|
|
660
|
+
) -> pd.DataFrame:
|
|
661
|
+
"""Combine two dataframes that may have identical columns, prioritizing the first one.
|
|
662
|
+
|
|
663
|
+
If dataframes have a dummy index, index_columns have to be specified (and must be a column in both dataframes).
|
|
664
|
+
If dataframes have a single/multi index, index_columns must be left as None.
|
|
665
|
+
|
|
666
|
+
Suppose you have two dataframes, df1 and df2, both having columns "col_a" and "col_b", and we want to create a
|
|
667
|
+
combined dataframe with the union of rows and columns, and, on the overlapping elements, prioritize df1 values.
|
|
668
|
+
To do this, you could:
|
|
669
|
+
* Merge the dataframes. But then the result would have columns "col_a_x", "col_a_y", "col_b_x", and "col_b_y".
|
|
670
|
+
* Concatenate them and then drop duplicates (for example keeping the last repetition). This works, but, if df1 has
|
|
671
|
+
nans then we would keep those nans.
|
|
672
|
+
To solve these problems, this function will not create new columns, and will prioritize df1, but filling missing
|
|
673
|
+
values in df1 with data from df2.
|
|
674
|
+
|
|
675
|
+
Parameters
|
|
676
|
+
----------
|
|
677
|
+
df1 : pd.DataFrame
|
|
678
|
+
First dataframe (the one that has priority).
|
|
679
|
+
df2 : pd.DataFrame
|
|
680
|
+
Second dataframe.
|
|
681
|
+
index_columns : list or None
|
|
682
|
+
Columns (that must be present in both dataframes, and not as index columns) that should be treated as index
|
|
683
|
+
(e.g. ["country", "year"]). If None, the single/multi index of the dataframes will be used.
|
|
684
|
+
keep_column_order : bool
|
|
685
|
+
True to keep the column order of the original dataframes (first all columns in df1, then all columns from df2
|
|
686
|
+
that were not already in df1). False to sort columns alphanumerically.
|
|
687
|
+
|
|
688
|
+
Returns
|
|
689
|
+
-------
|
|
690
|
+
combined : pd.DataFrame
|
|
691
|
+
Combination of the two dataframes.
|
|
692
|
+
|
|
693
|
+
"""
|
|
694
|
+
df1 = df1.copy()
|
|
695
|
+
df2 = df2.copy()
|
|
696
|
+
if index_columns is not None:
|
|
697
|
+
# Ensure dataframes have a dummy index.
|
|
698
|
+
if not ((df1.index.names == [None]) and (df2.index.names == [None])):
|
|
699
|
+
warnings.warn("If index_columns is given, dataframes should have a dummy index. Use" " reset_index().")
|
|
700
|
+
# Set index columns.
|
|
701
|
+
df1 = df1.set_index(index_columns)
|
|
702
|
+
df2 = df2.set_index(index_columns)
|
|
703
|
+
else:
|
|
704
|
+
# Ensure dataframes have the same indexes.
|
|
705
|
+
if not (df1.index.names == df2.index.names):
|
|
706
|
+
warnings.warn("Dataframes should have the same indexes.")
|
|
707
|
+
|
|
708
|
+
# Align both dataframes on their common indexes.
|
|
709
|
+
# Give priority to df1 on overlapping values.
|
|
710
|
+
combined, df2 = df1.align(df2)
|
|
711
|
+
|
|
712
|
+
new_columns = df2.columns.difference(df1.columns)
|
|
713
|
+
for col in new_columns:
|
|
714
|
+
try:
|
|
715
|
+
combined[col] = combined[col].astype(df2[col].dtype, copy=False)
|
|
716
|
+
except ValueError:
|
|
717
|
+
# casting NaNs to integer will fail
|
|
718
|
+
pass
|
|
719
|
+
|
|
720
|
+
# Fill missing values in df1 with values from df2.
|
|
721
|
+
combined = combined.fillna(df2)
|
|
722
|
+
|
|
723
|
+
if index_columns is not None:
|
|
724
|
+
combined = combined.reset_index()
|
|
725
|
+
|
|
726
|
+
if keep_column_order:
|
|
727
|
+
# The previous operations will automatically sort columns alphanumerically.
|
|
728
|
+
# To keep the original order of columns, we need to find that sequence of columns.
|
|
729
|
+
# First, columns of df1, and then all columns in df2 that were not in df1.
|
|
730
|
+
if index_columns is not None:
|
|
731
|
+
columns = index_columns + df1.columns.tolist()
|
|
732
|
+
else:
|
|
733
|
+
columns = df1.columns.tolist()
|
|
734
|
+
columns = columns + [column for column in df2.columns if column not in columns]
|
|
735
|
+
combined = combined[columns]
|
|
736
|
+
|
|
737
|
+
# It would be good to have a 'keep_row_order' option, but it's a bit tricky.
|
|
738
|
+
|
|
739
|
+
return cast(pd.DataFrame, combined)
|