whyvalue 0.1.0__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.
whyvalue/core.py ADDED
@@ -0,0 +1,638 @@
1
+ import pandas as pd
2
+
3
+ from .history import history
4
+ from .adapters import pandas as pandas_adapter
5
+
6
+
7
+ _watching = False
8
+
9
+
10
+ def watch():
11
+ """Start WhyValue tracking."""
12
+ global _watching
13
+
14
+ if _watching:
15
+ print("WhyValue is already watching.")
16
+ return
17
+
18
+ history.clear()
19
+ pandas_adapter.enable()
20
+
21
+ _watching = True
22
+
23
+ print("WhyValue is watching your data.")
24
+
25
+
26
+ def stop():
27
+ """Stop WhyValue tracking."""
28
+ global _watching
29
+
30
+ if not _watching:
31
+ print("WhyValue is not currently watching.")
32
+ return
33
+
34
+ pandas_adapter.disable()
35
+
36
+ _watching = False
37
+
38
+ print("WhyValue stopped watching.")
39
+
40
+
41
+ def is_watching():
42
+ """Return whether WhyValue is currently watching."""
43
+ return _watching
44
+
45
+
46
+ def _get_symbol(operation):
47
+ symbols = {
48
+ "add": "+",
49
+ "multiply": "×",
50
+ "subtract": "-",
51
+ "divide": "/",
52
+ }
53
+
54
+ return symbols.get(operation, "?")
55
+
56
+
57
+ def _get_dataframe_id(obj):
58
+ """Return the WhyValue lineage ID stored on a DataFrame or Series."""
59
+ if not hasattr(obj, "attrs"):
60
+ return None
61
+
62
+ return obj.attrs.get("_whyvalue_id") or obj.attrs.get("_whyvalue_dataframe_id")
63
+
64
+
65
+ def _event_matches_dataframe(event, dataframe_id):
66
+ """Return whether an event belongs to a DataFrame lineage."""
67
+ if dataframe_id is None:
68
+ return False
69
+
70
+ return event.get("dataframe_id") == dataframe_id
71
+
72
+
73
+ def _find_event(obj, column):
74
+ """Find the most recent transformation for a column."""
75
+ dataframe_id = _get_dataframe_id(obj)
76
+ events = history.get_all()
77
+
78
+ for event in reversed(events):
79
+ if (
80
+ _event_matches_dataframe(event, dataframe_id)
81
+ and (
82
+ event.get("column") == column
83
+ or (event["type"] == "rename" and column in event["columns"].values())
84
+ or event["type"] == "merge"
85
+ or (event["type"] == "groupby" and event.get("source") == column)
86
+ )
87
+ and event["type"] != "filter"
88
+ ):
89
+ return event
90
+
91
+ return None
92
+
93
+
94
+ def _find_events_by_dataframe_id(dataframe_id, column):
95
+ """Find all matching events for a dataframe ID + column in chronological order."""
96
+ if dataframe_id is None:
97
+ return []
98
+
99
+ events = history.get_all()
100
+ matching = []
101
+
102
+ for event in events:
103
+ if (
104
+ _event_matches_dataframe(event, dataframe_id)
105
+ and (
106
+ event.get("column") == column
107
+ or (event["type"] == "rename" and column in event["columns"].values())
108
+ or event["type"] == "merge"
109
+ or (event["type"] == "groupby" and event.get("source") == column)
110
+ )
111
+ and event["type"] != "filter"
112
+ ):
113
+ matching.append(event)
114
+
115
+ return matching
116
+
117
+
118
+ def _find_events(obj, column):
119
+ """Find all transformations for a column in chronological order."""
120
+ return _find_events_by_dataframe_id(_get_dataframe_id(obj), column)
121
+
122
+
123
+ def _find_event_by_type(obj, column, event_type):
124
+ """Find the most recent event of a specific type."""
125
+ dataframe_id = _get_dataframe_id(obj)
126
+ events = history.get_all()
127
+
128
+ for event in reversed(events):
129
+ if (
130
+ _event_matches_dataframe(event, dataframe_id)
131
+ and event.get("column") == column
132
+ and event["type"] == event_type
133
+ ):
134
+ return event
135
+
136
+ return None
137
+
138
+
139
+ def _find_filter_for_row(row, obj=None):
140
+ """Find the most recent row-removal event."""
141
+ events = history.get_all()
142
+
143
+ dataframe_id = None
144
+
145
+ if obj is not None:
146
+ dataframe_id = _get_dataframe_id(obj)
147
+
148
+ for event in reversed(events):
149
+ if event["type"] not in ("filter", "dropna", "combined_filter"):
150
+ continue
151
+
152
+ if not _event_matches_dataframe(
153
+ event,
154
+ dataframe_id,
155
+ ):
156
+ continue
157
+
158
+ if row in event["removed_rows"]:
159
+ return event
160
+
161
+ return None
162
+
163
+
164
+ def trace(obj):
165
+ """Show transformations belonging to a DataFrame."""
166
+ dataframe_id = _get_dataframe_id(obj)
167
+
168
+ events = [
169
+ event
170
+ for event in history.get_all()
171
+ if _event_matches_dataframe(
172
+ event,
173
+ dataframe_id,
174
+ )
175
+ ]
176
+
177
+ if not events:
178
+ print("No transformations recorded.")
179
+ return
180
+
181
+ print("WhyValue Trace")
182
+ print()
183
+
184
+ for event in events:
185
+ if event["type"] == "column_created":
186
+ symbol = _get_symbol(event["operation"])
187
+
188
+ print(f"{event['column']} = {event['left']} {symbol} {event['right']}")
189
+
190
+ elif event["type"] == "column_filled":
191
+ print(f"{event['column']}: missing values → {event['value']}")
192
+
193
+ elif event["type"] == "filter":
194
+ print(f"filter: {event['column']} {event['operator']} {event['value']}")
195
+
196
+
197
+ def _explain_column(obj, row, column):
198
+ """Explain one column and recursively explain dependencies."""
199
+ event = _find_event(obj, column)
200
+
201
+ if event is None:
202
+ value = obj.loc[row, column] if not isinstance(obj, pd.Series) else obj.loc[row]
203
+ print(f"{column} = {value}")
204
+ return
205
+
206
+ if event["type"] == "column_created":
207
+ if event.get("operation") == "copy":
208
+ result_value = (
209
+ obj.loc[row, column] if not isinstance(obj, pd.Series) else obj.loc[row]
210
+ )
211
+ source = event["source"]
212
+ print("Copied from column:")
213
+ print(source)
214
+ print()
215
+ print("Source: another DataFrame")
216
+ print()
217
+ print(f"{column} = {result_value}")
218
+ return
219
+
220
+ symbol = _get_symbol(event["operation"])
221
+
222
+ left_type = event.get(
223
+ "left_type",
224
+ "column",
225
+ )
226
+
227
+ right_type = event.get(
228
+ "right_type",
229
+ "column",
230
+ )
231
+
232
+ current_df_id = _get_dataframe_id(obj)
233
+
234
+ left_df_id = event.get("left_dataframe_id")
235
+ is_left_local = False
236
+ if left_type == "scalar":
237
+ is_left_local = True
238
+ left_value = event["left"]
239
+ else:
240
+ left_column = event["left"]
241
+ if (
242
+ (left_df_id is None or left_df_id == current_df_id)
243
+ and hasattr(obj, "columns")
244
+ and left_column in obj.columns
245
+ ):
246
+ is_left_local = True
247
+ _explain_column(
248
+ obj,
249
+ row,
250
+ left_column,
251
+ )
252
+ left_value = (
253
+ obj.loc[row, left_column]
254
+ if not isinstance(obj, pd.Series)
255
+ else obj.loc[row]
256
+ )
257
+ else:
258
+ is_left_local = False
259
+ left_value = None
260
+
261
+ right_df_id = event.get("right_dataframe_id")
262
+ is_right_local = False
263
+ if right_type == "scalar":
264
+ is_right_local = True
265
+ right_value = event["right"]
266
+ else:
267
+ right_column = event["right"]
268
+ if (
269
+ (right_df_id is None or right_df_id == current_df_id)
270
+ and hasattr(obj, "columns")
271
+ and right_column in obj.columns
272
+ ):
273
+ is_right_local = True
274
+ _explain_column(
275
+ obj,
276
+ row,
277
+ right_column,
278
+ )
279
+ right_value = (
280
+ obj.loc[row, right_column]
281
+ if not isinstance(obj, pd.Series)
282
+ else obj.loc[row]
283
+ )
284
+ else:
285
+ is_right_local = False
286
+ right_value = None
287
+
288
+ if left_type == "column" and not is_left_local:
289
+ print(f"{event['left']} came from another DataFrame.")
290
+ print()
291
+
292
+ if right_type == "column" and not is_right_local:
293
+ print(f"{event['right']} came from another DataFrame.")
294
+ print()
295
+
296
+ result_value = (
297
+ obj.loc[row, column] if not isinstance(obj, pd.Series) else obj.loc[row]
298
+ )
299
+
300
+ if is_left_local and is_right_local:
301
+ print()
302
+ print(f"{left_value} {symbol} {right_value}")
303
+ print(f"→ {column} = {result_value}")
304
+ else:
305
+ print("Operation:")
306
+ print(f"{event['left']} {symbol} {event['right']}")
307
+ print()
308
+ print(f"→ {column} = {result_value}")
309
+
310
+ elif event["type"] == "column_filled":
311
+ result_value = obj.loc[row, column]
312
+ fill_value = event["value"]
313
+
314
+ if row in event["missing_rows"]:
315
+ print("Original value:")
316
+ print("NaN")
317
+ print()
318
+
319
+ print("Transformation:")
320
+ print(f"fillna({fill_value})")
321
+ print()
322
+
323
+ print(f"NaN → {result_value}")
324
+
325
+ else:
326
+ print(f"{column} = {result_value}")
327
+ print()
328
+
329
+ print(f"fillna({fill_value}) did not change this row.")
330
+
331
+ elif event["type"] == "astype":
332
+ result_value = obj.loc[row, column]
333
+
334
+ print("Transformation:")
335
+ print(f"astype({event['dtype']})")
336
+ print()
337
+ print(f"{event['source']} → {column}")
338
+ print(f"{column} = {result_value}")
339
+
340
+ elif event["type"] == "round":
341
+ result_value = obj.loc[row, column]
342
+
343
+ print("Transformation:")
344
+ print(f"round({event['decimals']})")
345
+ print()
346
+ print(f"{event['source']} → {column}")
347
+ print(f"{column} = {result_value}")
348
+
349
+ elif event["type"] == "rename":
350
+ source = next(
351
+ source
352
+ for source, destination in event["columns"].items()
353
+ if destination == column
354
+ )
355
+ result_value = obj.loc[row, column]
356
+
357
+ print("Transformation:")
358
+ print("rename()")
359
+ print()
360
+ print(f"{source} → {column}")
361
+ print(f"{column} = {result_value}")
362
+
363
+ elif event["type"] == "map":
364
+ result_value = obj.loc[row, column]
365
+
366
+ print("Transformation:")
367
+ print("map()")
368
+ print()
369
+ print("Mapping:")
370
+ for key, value in event["mapping"].items():
371
+ print(f"{key} → {value}")
372
+
373
+ print()
374
+ print(f"{event['source']} → {column}")
375
+ print(f"{column} = {result_value}")
376
+
377
+ elif event["type"] == "apply":
378
+ result_value = obj.loc[row, column]
379
+
380
+ print("Transformation:")
381
+ print(f"apply({event['function']})")
382
+ print()
383
+ print(f"{event['source']} → {column}")
384
+ print(f"{column} = {result_value}")
385
+
386
+ elif event["type"] == "merge":
387
+ result_value = obj.loc[row, column]
388
+
389
+ how = event.get("how", "inner")
390
+ on = event.get("on")
391
+
392
+ if on:
393
+ if isinstance(on, list):
394
+ on_str = ", ".join(on)
395
+ else:
396
+ on_str = str(on)
397
+ merge_desc = f"{how} join on {on_str}"
398
+ else:
399
+ merge_desc = f"{how} join"
400
+
401
+ print("Merge:")
402
+ print(merge_desc)
403
+ print()
404
+ print("This DataFrame was created by combining two data sources.")
405
+ print()
406
+ print(f"{column} = {result_value}")
407
+
408
+ elif event["type"] == "groupby":
409
+ if isinstance(obj, pd.Series):
410
+ result_value = obj.loc[row]
411
+ else:
412
+ result_value = obj.loc[row, column]
413
+
414
+ group_by = event["group_by"]
415
+ aggregation = event["aggregation"]
416
+
417
+ source_df_id = event.get("source_dataframe_id")
418
+ source_col = event.get("source")
419
+
420
+ source_events = []
421
+ if source_df_id and source_col:
422
+ source_events = _find_events_by_dataframe_id(source_df_id, source_col)
423
+
424
+ if source_events:
425
+ print("Source transformation history:")
426
+ print()
427
+ for idx, src_event in enumerate(source_events, start=1):
428
+ print(f"{idx}. {_format_event_summary(src_event)}")
429
+
430
+ print()
431
+ print("Aggregation:")
432
+ print(f"{aggregation}()")
433
+ print()
434
+ print("Grouped by:")
435
+ print(f"{group_by} = {row}")
436
+ print()
437
+ print("Final:")
438
+ print(f"{column} = {result_value}")
439
+ else:
440
+ print("Aggregation:")
441
+ print(f"{aggregation}()")
442
+ print()
443
+ print("Grouped by:")
444
+ print(f"{group_by} = {row}")
445
+ print()
446
+ print(f"{column} = {result_value}")
447
+
448
+
449
+ def _format_event_summary(event):
450
+ """Format an event into a single line summary step."""
451
+ event_type = event.get("type")
452
+
453
+ if event_type == "column_created":
454
+ op = event.get("operation")
455
+ if op == "copy":
456
+ return f"copy from {event.get('source')}"
457
+
458
+ symbol = _get_symbol(op)
459
+ left = event.get("left")
460
+ right = event.get("right")
461
+ col = event.get("column")
462
+ return f"{left} {symbol} {right} → {col}"
463
+
464
+ elif event_type == "column_filled":
465
+ return f"fillna({event.get('value')})"
466
+
467
+ elif event_type == "astype":
468
+ return f"astype({event.get('dtype')})"
469
+
470
+ elif event_type == "round":
471
+ return f"round({event.get('decimals')})"
472
+
473
+ elif event_type == "map":
474
+ return "map()"
475
+
476
+ elif event_type == "apply":
477
+ return f"apply({event.get('function')})"
478
+
479
+ elif event_type == "rename":
480
+ return "rename()"
481
+
482
+ elif event_type == "merge":
483
+ return "merge()"
484
+
485
+ elif event_type == "groupby":
486
+ return f"{event.get('aggregation')}()"
487
+
488
+ return f"{event_type}()"
489
+
490
+
491
+ def _explain_event_chain(obj, row, column, events):
492
+ """Explain a sequence of transformations on the same column."""
493
+ if isinstance(obj, pd.Series):
494
+ result_value = obj.loc[row]
495
+ else:
496
+ result_value = obj.loc[row, column]
497
+
498
+ print("Transformation history:")
499
+ print()
500
+ for idx, event in enumerate(events, start=1):
501
+ print(f"{idx}. {_format_event_summary(event)}")
502
+
503
+ print()
504
+ print("Final:")
505
+ print(f"{column} = {result_value}")
506
+
507
+
508
+ def explain(obj, row, column=None):
509
+ """Explain why a specific cell has its current value."""
510
+ if isinstance(obj, pd.Series) and column is None:
511
+ column = obj.name
512
+
513
+ events = _find_events(
514
+ obj,
515
+ column,
516
+ )
517
+
518
+ if not events:
519
+ print(f"No explanation found for '{column}'.")
520
+ return
521
+
522
+ if isinstance(obj, pd.Series):
523
+ result_value = obj.loc[row]
524
+ else:
525
+ result_value = obj.loc[row, column]
526
+
527
+ print(f"Why is {column} = {result_value}?")
528
+ print()
529
+
530
+ if len(events) == 1:
531
+ _explain_column(
532
+ obj,
533
+ row,
534
+ column,
535
+ )
536
+ else:
537
+ _explain_event_chain(
538
+ obj,
539
+ row,
540
+ column,
541
+ events,
542
+ )
543
+
544
+
545
+ def explain_removed(obj=None, row=None):
546
+ """Explain why a row was removed by a filter or dropna."""
547
+
548
+ if row is None:
549
+ print("Please provide a row.")
550
+ return
551
+
552
+ filter_event = _find_filter_for_row(
553
+ row,
554
+ obj,
555
+ )
556
+
557
+ if filter_event is None:
558
+ print(f"No removal explanation found for row {row}.")
559
+ return
560
+
561
+ if filter_event["type"] == "dropna":
562
+ print(f"Why was row {row} removed?")
563
+ print()
564
+ print(f"dropna(subset={filter_event['subset']})")
565
+ print()
566
+ print("Row removed because required data was missing.")
567
+ return
568
+
569
+ if filter_event["type"] == "combined_filter":
570
+ print(f"Why was row {row} removed?")
571
+ print()
572
+ print(f"Combined filter ({filter_event['logic'].upper()}):")
573
+ print()
574
+
575
+ for condition in filter_event["conditions"]:
576
+ print(f"{condition['column']} {condition['operator']} {condition['value']}")
577
+
578
+ print()
579
+ if filter_event["logic"] == "and":
580
+ print("Row removed because the row did not satisfy all conditions.")
581
+
582
+ elif filter_event["logic"] == "or":
583
+ print("Row removed because the row did not satisfy any condition.")
584
+ return
585
+
586
+ column = filter_event["column"]
587
+ operator = filter_event["operator"]
588
+ threshold = filter_event["value"]
589
+ row_value = filter_event["removed_values"][row]
590
+
591
+ print(f"Why was row {row} removed?")
592
+ print()
593
+
594
+ fill_event = None
595
+
596
+ if obj is not None:
597
+ fill_event = _find_event_by_type(
598
+ obj,
599
+ column,
600
+ "column_filled",
601
+ )
602
+
603
+ if fill_event is None:
604
+ dataframe_id = filter_event.get("source_dataframe_id") or filter_event.get(
605
+ "dataframe_id"
606
+ )
607
+
608
+ for event in reversed(history.get_all()):
609
+ if (
610
+ event.get("dataframe_id") == dataframe_id
611
+ and event.get("column") == column
612
+ and event["type"] == "column_filled"
613
+ ):
614
+ fill_event = event
615
+ break
616
+
617
+ if fill_event is not None and row in fill_event["missing_rows"]:
618
+ fill_value = fill_event["value"]
619
+
620
+ print(f"Original {column}:")
621
+ print("NaN")
622
+ print()
623
+
624
+ print("Transformation:")
625
+ print(f"fillna({fill_value})")
626
+ print()
627
+
628
+ print(f"NaN → {row_value}")
629
+ print()
630
+
631
+ print("Filter:")
632
+ print(f"{column} {operator} {threshold}")
633
+ print()
634
+
635
+ print(f"{row_value} {operator} {threshold} → False")
636
+ print()
637
+
638
+ print("Row removed")
whyvalue/history.py ADDED
@@ -0,0 +1,15 @@
1
+ class History:
2
+ def __init__(self):
3
+ self.events = []
4
+
5
+ def add(self, event):
6
+ self.events.append(event)
7
+
8
+ def get_all(self):
9
+ return self.events
10
+
11
+ def clear(self):
12
+ self.events.clear()
13
+
14
+
15
+ history = History()