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.
@@ -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"]