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.
@@ -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)