do-attribution 0.0.1.post1__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.
- do_attribution/__init__.py +115 -0
- do_attribution/base.py +147 -0
- do_attribution/compare.py +138 -0
- do_attribution/markov.py +502 -0
- do_attribution/reporting.py +822 -0
- do_attribution/shapley.py +244 -0
- do_attribution/temporal.py +1546 -0
- do_attribution/temporal_diagnostics.py +214 -0
- do_attribution/temporal_markov.py +1509 -0
- do_attribution/temporal_shapley.py +1383 -0
- do_attribution/utils.py +299 -0
- do_attribution/visualization.py +333 -0
- do_attribution-0.0.1.post1.dist-info/METADATA +411 -0
- do_attribution-0.0.1.post1.dist-info/RECORD +17 -0
- do_attribution-0.0.1.post1.dist-info/WHEEL +5 -0
- do_attribution-0.0.1.post1.dist-info/licenses/LICENSE +21 -0
- do_attribution-0.0.1.post1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
do_attribution
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Multi-touch attribution for Python. Exposes two statistical allocation
|
|
6
|
+
models -- an absorbing-Markov-chain removal-effect model and a
|
|
7
|
+
cooperative-game-theoretic Shapley-value model -- through a single
|
|
8
|
+
:class:`BaseAttribution` interface, so callers can switch models or
|
|
9
|
+
fit both and compare them without changing their call site.
|
|
10
|
+
|
|
11
|
+
Quick start
|
|
12
|
+
-----------
|
|
13
|
+
|
|
14
|
+
>>> from do_attribution import MarkovAttribution, ShapleyAttribution, CompareAttribution
|
|
15
|
+
>>> paths = ['A > B > C', 'A > C', 'B > C']
|
|
16
|
+
>>> conversions = [1, 0, 1]
|
|
17
|
+
>>> MarkovAttribution(order=1).fit(paths, conversions)['attribution'] # doctest: +SKIP
|
|
18
|
+
>>> ShapleyAttribution().fit(paths, conversions)['attribution'] # doctest: +SKIP
|
|
19
|
+
>>> CompareAttribution().fit(paths, conversions) # doctest: +SKIP
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .base import BaseAttribution
|
|
23
|
+
from .markov import MarkovAttribution
|
|
24
|
+
from .shapley import ShapleyAttribution
|
|
25
|
+
from .compare import CompareAttribution
|
|
26
|
+
# MODIFIED: export TemporalShapleyAttribution now that the module lives
|
|
27
|
+
# inside the package (previously a workspace-root draft).
|
|
28
|
+
from .temporal_shapley import TemporalShapleyAttribution
|
|
29
|
+
# MODIFIED (temporal v1 punch list): export TemporalMarkovAttribution
|
|
30
|
+
# alongside its Shapley sibling, plus the suggest_freq diagnostic.
|
|
31
|
+
from .temporal_markov import TemporalMarkovAttribution
|
|
32
|
+
from .temporal_diagnostics import suggest_freq
|
|
33
|
+
from .temporal import (
|
|
34
|
+
BoundaryEvent,
|
|
35
|
+
DaySummary,
|
|
36
|
+
DecompositionResult,
|
|
37
|
+
EpochRecommendation,
|
|
38
|
+
EpochSufficiencyError,
|
|
39
|
+
EpochWidthError,
|
|
40
|
+
MergeAction,
|
|
41
|
+
NonCalendarBoundaryError,
|
|
42
|
+
PinAction,
|
|
43
|
+
PinOverlapError,
|
|
44
|
+
SegmentFit,
|
|
45
|
+
SignalContinuityError,
|
|
46
|
+
SignalShapeError,
|
|
47
|
+
SignalSufficiencyError,
|
|
48
|
+
StreamingEpochRecommender,
|
|
49
|
+
TemporalRecommendationError,
|
|
50
|
+
WeightedAffineCost,
|
|
51
|
+
assign_paths_to_epochs,
|
|
52
|
+
recommend_epochs,
|
|
53
|
+
)
|
|
54
|
+
from .reporting import TemporalReportingSnapshot
|
|
55
|
+
from .utils import (
|
|
56
|
+
DEFAULT_SEPARATOR,
|
|
57
|
+
SPECIAL_TOKENS,
|
|
58
|
+
create_n_order_states,
|
|
59
|
+
extract_channels,
|
|
60
|
+
get_powerset,
|
|
61
|
+
split_path,
|
|
62
|
+
strip_conversion_labels, # MODIFIED: P0 export.
|
|
63
|
+
strip_conversion_labels_array, # MODIFIED: P0 export.
|
|
64
|
+
to_int_array,
|
|
65
|
+
to_string_array,
|
|
66
|
+
validate_data,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__version__ = "0.0.1.post1"
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
# Classes
|
|
73
|
+
"BaseAttribution",
|
|
74
|
+
"MarkovAttribution",
|
|
75
|
+
"ShapleyAttribution",
|
|
76
|
+
"CompareAttribution",
|
|
77
|
+
# MODIFIED: temporal classes exported alongside their static siblings.
|
|
78
|
+
"TemporalShapleyAttribution",
|
|
79
|
+
"TemporalMarkovAttribution",
|
|
80
|
+
# MODIFIED: temporal diagnostics.
|
|
81
|
+
"suggest_freq",
|
|
82
|
+
# Global-signal temporal recommender.
|
|
83
|
+
"BoundaryEvent",
|
|
84
|
+
"DaySummary",
|
|
85
|
+
"DecompositionResult",
|
|
86
|
+
"EpochRecommendation",
|
|
87
|
+
"EpochSufficiencyError",
|
|
88
|
+
"EpochWidthError",
|
|
89
|
+
"MergeAction",
|
|
90
|
+
"NonCalendarBoundaryError",
|
|
91
|
+
"PinAction",
|
|
92
|
+
"PinOverlapError",
|
|
93
|
+
"SegmentFit",
|
|
94
|
+
"SignalContinuityError",
|
|
95
|
+
"SignalShapeError",
|
|
96
|
+
"SignalSufficiencyError",
|
|
97
|
+
"StreamingEpochRecommender",
|
|
98
|
+
"TemporalRecommendationError",
|
|
99
|
+
"TemporalReportingSnapshot",
|
|
100
|
+
"WeightedAffineCost",
|
|
101
|
+
"assign_paths_to_epochs",
|
|
102
|
+
"recommend_epochs",
|
|
103
|
+
# Utils
|
|
104
|
+
"DEFAULT_SEPARATOR",
|
|
105
|
+
"SPECIAL_TOKENS",
|
|
106
|
+
"create_n_order_states",
|
|
107
|
+
"extract_channels",
|
|
108
|
+
"get_powerset",
|
|
109
|
+
"split_path",
|
|
110
|
+
"strip_conversion_labels", # MODIFIED: P0 export.
|
|
111
|
+
"strip_conversion_labels_array", # MODIFIED: P0 export.
|
|
112
|
+
"to_int_array",
|
|
113
|
+
"to_string_array",
|
|
114
|
+
"validate_data",
|
|
115
|
+
]
|
do_attribution/base.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
do_attribution.base
|
|
3
|
+
------------------------
|
|
4
|
+
|
|
5
|
+
Abstract base class shared by every attribution model in the library.
|
|
6
|
+
Guarantees a single, consistent ``fit`` / ``get_attribution`` API so that
|
|
7
|
+
code can switch between Markov-chain and Shapley-value attribution without
|
|
8
|
+
any changes other than the constructor name.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Union
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from .utils import DEFAULT_SEPARATOR, _normalize_conversion_labels # MODIFIED: P0 imports
|
|
20
|
+
from .reporting import (
|
|
21
|
+
TemporalReportingSnapshot,
|
|
22
|
+
build_epoch_metrics,
|
|
23
|
+
render_temporal_report,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseAttribution(ABC):
|
|
28
|
+
"""Common interface for all attribution models.
|
|
29
|
+
|
|
30
|
+
Subclasses must implement :meth:`fit`. They should populate
|
|
31
|
+
``self.channels`` and ``self._last_attribution`` so that
|
|
32
|
+
:meth:`get_attribution` and :meth:`get_channels` work uniformly.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
separator : str, default=' > '
|
|
37
|
+
Delimiter used to split path strings into channels.
|
|
38
|
+
conversion_labels : iterable of str, optional
|
|
39
|
+
Tokens in the path strings that represent the conversion endpoint
|
|
40
|
+
(e.g. ``"Purchase"``) rather than a real marketing channel. When
|
|
41
|
+
supplied, these tokens are stripped from every path *before* channel
|
|
42
|
+
extraction, state creation, and attribution math, so they never
|
|
43
|
+
appear in ``attribution``, ``removal_effects``, the transition
|
|
44
|
+
matrix, Shapley values, or any downstream viz. Case-sensitive exact
|
|
45
|
+
match. Defaults to ``None`` (every token is treated as a channel,
|
|
46
|
+
matching the pre-P0 behaviour). Raises ``ValueError`` if any label
|
|
47
|
+
collides with :data:`~do_attribution.utils.SPECIAL_TOKENS`.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
separator: str = DEFAULT_SEPARATOR,
|
|
53
|
+
conversion_labels: Optional[Iterable[str]] = None, # MODIFIED: P0 parameter.
|
|
54
|
+
):
|
|
55
|
+
self.separator: str = separator
|
|
56
|
+
# MODIFIED: normalise + validate once at construction time so every
|
|
57
|
+
# downstream operation can rely on a frozenset[str] (possibly empty).
|
|
58
|
+
self.conversion_labels: "frozenset[str]" = _normalize_conversion_labels(
|
|
59
|
+
conversion_labels
|
|
60
|
+
)
|
|
61
|
+
self.channels: List[str] = []
|
|
62
|
+
self._last_attribution: Dict[str, float] = {}
|
|
63
|
+
self._static_reporting_snapshot: TemporalReportingSnapshot | None = None
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------ #
|
|
66
|
+
# Required API #
|
|
67
|
+
# ------------------------------------------------------------------ #
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def fit(
|
|
70
|
+
self,
|
|
71
|
+
paths: Union[Sequence[str], np.ndarray, pd.Series],
|
|
72
|
+
conversions: Union[Sequence[int], np.ndarray, pd.Series],
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
"""Fit the model and return the results dict.
|
|
75
|
+
|
|
76
|
+
Every subclass must at minimum include an ``'attribution'`` key
|
|
77
|
+
whose value is a ``dict[str, float]`` normalised to sum to 1.
|
|
78
|
+
"""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------ #
|
|
82
|
+
# Convenience helpers (concrete) #
|
|
83
|
+
# ------------------------------------------------------------------ #
|
|
84
|
+
def get_attribution(self) -> Dict[str, float]:
|
|
85
|
+
"""Return the attribution dict from the last :meth:`fit` call."""
|
|
86
|
+
if not self._last_attribution:
|
|
87
|
+
raise ValueError("No attribution available. Call fit() first.")
|
|
88
|
+
return dict(self._last_attribution)
|
|
89
|
+
|
|
90
|
+
def get_channels(self) -> List[str]:
|
|
91
|
+
"""Return the sorted channel list learned during the last fit."""
|
|
92
|
+
return list(self.channels)
|
|
93
|
+
|
|
94
|
+
def summary(self, output_format: str = "summary") -> str:
|
|
95
|
+
"""Render a plain-text summary or report for a fitted static model.
|
|
96
|
+
|
|
97
|
+
Static fits use the same deterministic renderer as temporal fits with
|
|
98
|
+
one ``All paths`` cohort and no timestamp boundaries. Temporal
|
|
99
|
+
subclasses override this method with their multi-epoch snapshots.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
if self._static_reporting_snapshot is None:
|
|
103
|
+
raise RuntimeError("summary() requires a fitted attribution model")
|
|
104
|
+
return render_temporal_report(self._static_reporting_snapshot, output_format)
|
|
105
|
+
|
|
106
|
+
def _store_static_reporting(
|
|
107
|
+
self,
|
|
108
|
+
paths: Union[Sequence[str], np.ndarray, pd.Series],
|
|
109
|
+
conversions: Union[Sequence[int], np.ndarray, pd.Series],
|
|
110
|
+
model_details: tuple = (), # MODIFIED: distilled model sections
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Store the all-data snapshot after a successful static fit."""
|
|
113
|
+
|
|
114
|
+
path_count = len(paths)
|
|
115
|
+
conversion_count = int(np.asarray(conversions, dtype=int).sum())
|
|
116
|
+
metrics = build_epoch_metrics(
|
|
117
|
+
epoch_labels=["All paths"],
|
|
118
|
+
starts=[pd.NaT],
|
|
119
|
+
ends=[pd.NaT],
|
|
120
|
+
path_counts=[path_count],
|
|
121
|
+
conversion_counts=[conversion_count],
|
|
122
|
+
sample_floors=0,
|
|
123
|
+
)
|
|
124
|
+
self._static_reporting_snapshot = TemporalReportingSnapshot(
|
|
125
|
+
model_name=self.__class__.__name__,
|
|
126
|
+
epoch_source="static",
|
|
127
|
+
epoch_labels=("All paths",),
|
|
128
|
+
boundaries=None,
|
|
129
|
+
epoch_metrics=metrics,
|
|
130
|
+
attribution_matrix=pd.DataFrame([dict(self._last_attribution)]),
|
|
131
|
+
sample_floor=0,
|
|
132
|
+
model_details=tuple(model_details), # MODIFIED
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------ #
|
|
136
|
+
# Introspection #
|
|
137
|
+
# ------------------------------------------------------------------ #
|
|
138
|
+
@property
|
|
139
|
+
def name(self) -> str:
|
|
140
|
+
"""Short human-readable name of the concrete model."""
|
|
141
|
+
return self.__class__.__name__
|
|
142
|
+
|
|
143
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
144
|
+
return f"<{self.name} separator={self.separator!r}>"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = ["BaseAttribution"]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
do_attribution.compare
|
|
3
|
+
---------------------------
|
|
4
|
+
|
|
5
|
+
Thin wrapper that fits both the :class:`MarkovAttribution` and
|
|
6
|
+
:class:`ShapleyAttribution` models on the same data and returns a
|
|
7
|
+
side-by-side comparison so callers can inspect differences in the
|
|
8
|
+
attribution distributions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Union
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from .base import BaseAttribution
|
|
19
|
+
from .markov import MarkovAttribution
|
|
20
|
+
from .shapley import ShapleyAttribution
|
|
21
|
+
from .utils import DEFAULT_SEPARATOR, extract_channels
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CompareAttribution:
|
|
25
|
+
"""Run Markov and Shapley attribution on the same data and compare.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
markov_order : int, default=1
|
|
30
|
+
Order of the Markov chain used internally.
|
|
31
|
+
markov_removal_strategy : {'fail', 'detour'}, default='fail'
|
|
32
|
+
Removal strategy forwarded to :class:`MarkovAttribution`.
|
|
33
|
+
separator : str, default=' > '
|
|
34
|
+
Separator used to split path strings.
|
|
35
|
+
extra_models : dict[str, BaseAttribution], optional
|
|
36
|
+
Additional named attribution models to include in the comparison.
|
|
37
|
+
Each model must implement the :class:`BaseAttribution` API.
|
|
38
|
+
conversion_labels : iterable of str, optional
|
|
39
|
+
Tokens that represent the conversion endpoint (e.g. ``"Purchase"``)
|
|
40
|
+
rather than a real channel. Forwarded to both sub-models so they
|
|
41
|
+
strip the endpoint tokens before any attribution math. See
|
|
42
|
+
:class:`do_attribution.base.BaseAttribution` for full semantics.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
markov_order: int = 1,
|
|
48
|
+
markov_removal_strategy: str = "fail",
|
|
49
|
+
separator: str = DEFAULT_SEPARATOR,
|
|
50
|
+
extra_models: Optional[Dict[str, BaseAttribution]] = None,
|
|
51
|
+
conversion_labels: Optional[Iterable[str]] = None, # MODIFIED: P0.
|
|
52
|
+
) -> None:
|
|
53
|
+
self.separator = separator
|
|
54
|
+
# MODIFIED (P0): store conversion_labels so both extract_channels()
|
|
55
|
+
# calls and forwarded sub-models exclude the endpoint tokens. Keep
|
|
56
|
+
# a list copy (not a frozenset) so we can re-pass it unchanged.
|
|
57
|
+
self.conversion_labels: Optional[List[str]] = (
|
|
58
|
+
list(conversion_labels) if conversion_labels is not None else None
|
|
59
|
+
)
|
|
60
|
+
self.markov = MarkovAttribution(
|
|
61
|
+
order=markov_order,
|
|
62
|
+
separator=separator,
|
|
63
|
+
removal_strategy=markov_removal_strategy,
|
|
64
|
+
conversion_labels=conversion_labels, # MODIFIED: P0 forward.
|
|
65
|
+
)
|
|
66
|
+
self.shapley = ShapleyAttribution(
|
|
67
|
+
separator=separator,
|
|
68
|
+
conversion_labels=conversion_labels, # MODIFIED: P0 forward.
|
|
69
|
+
)
|
|
70
|
+
self.extra_models: Dict[str, BaseAttribution] = dict(extra_models or {})
|
|
71
|
+
|
|
72
|
+
self._results: Dict[str, Dict[str, Any]] = {}
|
|
73
|
+
self.channels: List[str] = []
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------ #
|
|
76
|
+
# Public API #
|
|
77
|
+
# ------------------------------------------------------------------ #
|
|
78
|
+
def fit(
|
|
79
|
+
self,
|
|
80
|
+
paths: Union[Sequence[str], np.ndarray, pd.Series],
|
|
81
|
+
conversions: Union[Sequence[int], np.ndarray, pd.Series],
|
|
82
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
83
|
+
"""Fit every model and return their individual result dicts.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
dict
|
|
88
|
+
Keys are the model names (``'markov'``, ``'shapley'``, plus any
|
|
89
|
+
keys from ``extra_models``). Values are the raw result dicts
|
|
90
|
+
returned by each model's own ``fit``.
|
|
91
|
+
"""
|
|
92
|
+
# MODIFIED (P0): pass conversion_labels to extract_channels so the
|
|
93
|
+
# endpoint tokens never show up in self.channels / attribution_frame.
|
|
94
|
+
self.channels = extract_channels(
|
|
95
|
+
paths, self.separator, conversion_labels=self.conversion_labels
|
|
96
|
+
)
|
|
97
|
+
self._results = {
|
|
98
|
+
"markov": self.markov.fit(paths, conversions),
|
|
99
|
+
"shapley": self.shapley.fit(paths, conversions),
|
|
100
|
+
}
|
|
101
|
+
for name, model in self.extra_models.items():
|
|
102
|
+
self._results[name] = model.fit(paths, conversions)
|
|
103
|
+
return self._results
|
|
104
|
+
|
|
105
|
+
def attribution_frame(self) -> pd.DataFrame:
|
|
106
|
+
"""Return a DataFrame with one row per channel and one column per model.
|
|
107
|
+
|
|
108
|
+
Columns are the normalised attribution shares (sum to 1 within
|
|
109
|
+
each column). Channels are sorted alphabetically.
|
|
110
|
+
"""
|
|
111
|
+
if not self._results:
|
|
112
|
+
raise ValueError("No results available. Call fit() first.")
|
|
113
|
+
|
|
114
|
+
frame = pd.DataFrame(index=sorted(self.channels))
|
|
115
|
+
for name, res in self._results.items():
|
|
116
|
+
frame[name] = pd.Series(res["attribution"])
|
|
117
|
+
# Channels absent from a particular model fill as 0.
|
|
118
|
+
return frame.fillna(0.0).sort_index()
|
|
119
|
+
|
|
120
|
+
def difference_frame(self, reference: str = "markov") -> pd.DataFrame:
|
|
121
|
+
"""Return per-channel differences vs a reference model's share."""
|
|
122
|
+
frame = self.attribution_frame()
|
|
123
|
+
if reference not in frame.columns:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"reference={reference!r} not in results {list(frame.columns)}"
|
|
126
|
+
)
|
|
127
|
+
diff = frame.subtract(frame[reference], axis=0)
|
|
128
|
+
diff.columns = [f"{c}_minus_{reference}" for c in diff.columns]
|
|
129
|
+
return diff.drop(columns=[f"{reference}_minus_{reference}"])
|
|
130
|
+
|
|
131
|
+
def get_results(self) -> Dict[str, Dict[str, Any]]:
|
|
132
|
+
"""Return the raw results dict from the last :meth:`fit`."""
|
|
133
|
+
if not self._results:
|
|
134
|
+
raise ValueError("No results available. Call fit() first.")
|
|
135
|
+
return self._results
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
__all__ = ["CompareAttribution"]
|