mta-audit 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.
Files changed (48) hide show
  1. mta_audit/__init__.py +196 -0
  2. mta_audit/attribution/__init__.py +31 -0
  3. mta_audit/attribution/base.py +110 -0
  4. mta_audit/attribution/first_touch.py +5 -0
  5. mta_audit/attribution/last_touch.py +5 -0
  6. mta_audit/attribution/linear.py +5 -0
  7. mta_audit/attribution/markov.py +291 -0
  8. mta_audit/attribution/rules.py +33 -0
  9. mta_audit/audit.py +557 -0
  10. mta_audit/audits/__init__.py +83 -0
  11. mta_audit/audits/channel_concentration.py +117 -0
  12. mta_audit/audits/concentration.py +15 -0
  13. mta_audit/audits/contamination.py +178 -0
  14. mta_audit/audits/conversion_window.py +243 -0
  15. mta_audit/audits/data_quality.py +13 -0
  16. mta_audit/audits/exposure.py +189 -0
  17. mta_audit/audits/identity.py +39 -0
  18. mta_audit/audits/leakage.py +267 -0
  19. mta_audit/audits/model_disagreement.py +152 -0
  20. mta_audit/audits/model_stability.py +217 -0
  21. mta_audit/audits/path_quality.py +12 -0
  22. mta_audit/audits/path_sparsity.py +97 -0
  23. mta_audit/bootstrap.py +195 -0
  24. mta_audit/checks.py +476 -0
  25. mta_audit/core.py +30 -0
  26. mta_audit/datasets/__init__.py +58 -0
  27. mta_audit/datasets/criteo.py +331 -0
  28. mta_audit/datasets/evaluation.py +47 -0
  29. mta_audit/datasets/external.py +431 -0
  30. mta_audit/datasets/scenarios.py +149 -0
  31. mta_audit/datasets/synthetic.py +168 -0
  32. mta_audit/journeys.py +423 -0
  33. mta_audit/py.typed +1 -0
  34. mta_audit/results.py +647 -0
  35. mta_audit/robustness.py +341 -0
  36. mta_audit/sampling.py +96 -0
  37. mta_audit/schema.py +190 -0
  38. mta_audit/scoring.py +157 -0
  39. mta_audit/simulation/__init__.py +17 -0
  40. mta_audit/simulation/corruption.py +450 -0
  41. mta_audit/utils/__init__.py +10 -0
  42. mta_audit/utils/validation.py +5 -0
  43. mta_audit/visualization/__init__.py +19 -0
  44. mta_audit/visualization/plots.py +128 -0
  45. mta_audit-0.1.0.dist-info/METADATA +389 -0
  46. mta_audit-0.1.0.dist-info/RECORD +48 -0
  47. mta_audit-0.1.0.dist-info/WHEEL +4 -0
  48. mta_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
mta_audit/__init__.py ADDED
@@ -0,0 +1,196 @@
1
+ """Public API for mta-audit."""
2
+
3
+ from .attribution import (
4
+ ATTRIBUTION_COLUMNS,
5
+ AttributionError,
6
+ AttributionModel,
7
+ AttributionResult,
8
+ FirstTouchAttribution,
9
+ LastTouchAttribution,
10
+ LinearAttribution,
11
+ MarkovAttribution,
12
+ MarkovChainAttribution,
13
+ )
14
+ from .audit import MTAAudit
15
+ from .audits import (
16
+ ChannelConcentrationMetrics,
17
+ ConcentrationStats,
18
+ ContaminationMetrics,
19
+ ContaminationThresholds,
20
+ ConversionWindowMetrics,
21
+ ConversionWindowThresholds,
22
+ ExposureComparisonMetrics,
23
+ ExposureGroupStats,
24
+ ExposureThresholds,
25
+ LeakageSimulationMetrics,
26
+ ModelDisagreementMetrics,
27
+ PathSparsityMetrics,
28
+ SimulationScenario,
29
+ TemporalStabilityMetrics,
30
+ TemporalStabilityThresholds,
31
+ audit_channel_concentration,
32
+ audit_conversion_window,
33
+ audit_delayed_converter_contamination,
34
+ audit_event_loss,
35
+ audit_exposure_comparison,
36
+ audit_identity_loss,
37
+ audit_model_disagreement,
38
+ audit_model_stability,
39
+ audit_path_sparsity,
40
+ audit_temporal_stability,
41
+ audit_touchpoint_loss,
42
+ calculate_channel_concentration,
43
+ calculate_exposure_comparison,
44
+ calculate_model_disagreement,
45
+ calculate_path_sparsity,
46
+ resolve_attribution_model,
47
+ )
48
+ from .bootstrap import BootstrapConfig, BootstrapResult, bootstrap_attribution
49
+ from .checks import run_data_quality_checks, run_duplicate_checks
50
+ from .datasets import (
51
+ CriteoAttributionAdapter,
52
+ DatasetNotAvailableError,
53
+ SyntheticStressScenario,
54
+ load_criteo_attribution,
55
+ load_ignazio_attribution,
56
+ load_synthetic_attribution,
57
+ load_triangulation_mta,
58
+ make_stress_scenario,
59
+ simulate_criteo_label_events,
60
+ simulate_jd_mta_events,
61
+ simulate_synthesizer_events,
62
+ transform_ignazio_touchpoints,
63
+ transform_synthesizer_touchpoints,
64
+ transform_synthetic_attribution,
65
+ transform_triangulation_mta,
66
+ )
67
+ from .journeys import (
68
+ Journey,
69
+ JourneyBuilder,
70
+ JourneyCollection,
71
+ JourneyError,
72
+ JourneySummary,
73
+ Touchpoint,
74
+ build_journeys,
75
+ )
76
+ from .results import (
77
+ AuditFinding,
78
+ AuditReport,
79
+ AuditResult,
80
+ AuditScore,
81
+ EvidenceStatus,
82
+ Severity,
83
+ )
84
+ from .robustness import DecisionRobustnessResult, SensitivityResult
85
+ from .sampling import sample_for_audit
86
+ from .schema import ColumnMapping, SchemaError, ValidationSummary, validate_events
87
+ from .scoring import (
88
+ COMPONENT_CHECKS,
89
+ ScoreThresholds,
90
+ ScoringWeights,
91
+ calculate_reliability_score,
92
+ score_label,
93
+ )
94
+ from .simulation import (
95
+ CorruptedFrame,
96
+ CorruptionMetadata,
97
+ corrupt,
98
+ deduplicate,
99
+ detect_duplicates,
100
+ )
101
+
102
+ __all__ = [
103
+ "ATTRIBUTION_COLUMNS",
104
+ "COMPONENT_CHECKS",
105
+ "AttributionError",
106
+ "AttributionModel",
107
+ "AttributionResult",
108
+ "AuditFinding",
109
+ "AuditReport",
110
+ "AuditResult",
111
+ "AuditScore",
112
+ "BootstrapConfig",
113
+ "BootstrapResult",
114
+ "ChannelConcentrationMetrics",
115
+ "ColumnMapping",
116
+ "ConcentrationStats",
117
+ "ContaminationMetrics",
118
+ "ContaminationThresholds",
119
+ "ConversionWindowMetrics",
120
+ "ConversionWindowThresholds",
121
+ "CorruptedFrame",
122
+ "CorruptionMetadata",
123
+ "CriteoAttributionAdapter",
124
+ "DatasetNotAvailableError",
125
+ "DecisionRobustnessResult",
126
+ "EvidenceStatus",
127
+ "ExposureComparisonMetrics",
128
+ "ExposureGroupStats",
129
+ "ExposureThresholds",
130
+ "FirstTouchAttribution",
131
+ "Journey",
132
+ "JourneyBuilder",
133
+ "JourneyCollection",
134
+ "JourneyError",
135
+ "JourneySummary",
136
+ "LastTouchAttribution",
137
+ "LeakageSimulationMetrics",
138
+ "LinearAttribution",
139
+ "MTAAudit",
140
+ "MarkovAttribution",
141
+ "MarkovChainAttribution",
142
+ "ModelDisagreementMetrics",
143
+ "PathSparsityMetrics",
144
+ "SchemaError",
145
+ "ScoreThresholds",
146
+ "ScoringWeights",
147
+ "SensitivityResult",
148
+ "Severity",
149
+ "SimulationScenario",
150
+ "SyntheticStressScenario",
151
+ "TemporalStabilityMetrics",
152
+ "TemporalStabilityThresholds",
153
+ "Touchpoint",
154
+ "ValidationSummary",
155
+ "audit_channel_concentration",
156
+ "audit_conversion_window",
157
+ "audit_delayed_converter_contamination",
158
+ "audit_event_loss",
159
+ "audit_exposure_comparison",
160
+ "audit_identity_loss",
161
+ "audit_model_disagreement",
162
+ "audit_model_stability",
163
+ "audit_path_sparsity",
164
+ "audit_temporal_stability",
165
+ "audit_touchpoint_loss",
166
+ "bootstrap_attribution",
167
+ "build_journeys",
168
+ "calculate_channel_concentration",
169
+ "calculate_exposure_comparison",
170
+ "calculate_model_disagreement",
171
+ "calculate_path_sparsity",
172
+ "calculate_reliability_score",
173
+ "corrupt",
174
+ "deduplicate",
175
+ "detect_duplicates",
176
+ "load_criteo_attribution",
177
+ "load_ignazio_attribution",
178
+ "load_synthetic_attribution",
179
+ "load_triangulation_mta",
180
+ "make_stress_scenario",
181
+ "resolve_attribution_model",
182
+ "run_data_quality_checks",
183
+ "run_duplicate_checks",
184
+ "sample_for_audit",
185
+ "score_label",
186
+ "simulate_criteo_label_events",
187
+ "simulate_jd_mta_events",
188
+ "simulate_synthesizer_events",
189
+ "transform_ignazio_touchpoints",
190
+ "transform_synthesizer_touchpoints",
191
+ "transform_synthetic_attribution",
192
+ "transform_triangulation_mta",
193
+ "validate_events",
194
+ ]
195
+
196
+ __version__ = "0.1.0"
@@ -0,0 +1,31 @@
1
+ """Attribution models and their common interface."""
2
+
3
+ from .base import (
4
+ ATTRIBUTION_COLUMNS,
5
+ AttributionError,
6
+ AttributionModel,
7
+ AttributionResult,
8
+ )
9
+ from .markov import (
10
+ CONVERSION,
11
+ NULL,
12
+ START,
13
+ MarkovAttribution,
14
+ MarkovChainAttribution,
15
+ )
16
+ from .rules import FirstTouchAttribution, LastTouchAttribution, LinearAttribution
17
+
18
+ __all__ = [
19
+ "ATTRIBUTION_COLUMNS",
20
+ "CONVERSION",
21
+ "NULL",
22
+ "START",
23
+ "AttributionError",
24
+ "AttributionModel",
25
+ "AttributionResult",
26
+ "FirstTouchAttribution",
27
+ "LastTouchAttribution",
28
+ "LinearAttribution",
29
+ "MarkovAttribution",
30
+ "MarkovChainAttribution",
31
+ ]
@@ -0,0 +1,110 @@
1
+ """Shared attribution model interface and result types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections import defaultdict
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass
9
+
10
+ import pandas as pd
11
+
12
+ from ..journeys import Journey, JourneyCollection
13
+
14
+ ATTRIBUTION_COLUMNS = ["channel", "attribution_credit", "share", "conversions"]
15
+
16
+
17
+ class AttributionError(ValueError):
18
+ """Raised when attribution cannot be calculated."""
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class AttributionResult:
23
+ """Tabular channel attribution generated by a named model."""
24
+
25
+ model: str
26
+ data: pd.DataFrame
27
+
28
+ def __post_init__(self) -> None:
29
+ missing = set(ATTRIBUTION_COLUMNS) - set(self.data.columns)
30
+ if missing:
31
+ raise AttributionError(f"Attribution output is missing columns: {sorted(missing)}")
32
+
33
+
34
+ class AttributionModel(ABC):
35
+ """Common stateful interface implemented by all attribution models.
36
+
37
+ ``fit(journeys)`` stores the journey population and ``attribute()`` computes
38
+ attribution. For convenience and backwards compatibility,
39
+ ``attribute(journeys)`` fits and computes in one call.
40
+ """
41
+
42
+ name: str
43
+
44
+ def __init__(self) -> None:
45
+ self.journeys_: tuple[Journey, ...] | None = None
46
+
47
+ def fit(self, journeys: JourneyCollection | Iterable[Journey]) -> AttributionModel:
48
+ """Fit the model to a materialized journey population."""
49
+
50
+ records = journeys.journeys if isinstance(journeys, JourneyCollection) else tuple(journeys)
51
+ self.journeys_ = tuple(records)
52
+ return self
53
+
54
+ def attribute(
55
+ self, journeys: JourneyCollection | Iterable[Journey] | None = None
56
+ ) -> AttributionResult:
57
+ """Allocate conversion value and counts across channels."""
58
+
59
+ if journeys is not None:
60
+ self.fit(journeys)
61
+ if self.journeys_ is None:
62
+ raise AttributionError("Call fit(journeys) before attribute().")
63
+ return self._attribute_fitted()
64
+
65
+ def _attribute_fitted(self) -> AttributionResult:
66
+ value_credit: defaultdict[str, float] = defaultdict(float)
67
+ conversion_credit: defaultdict[str, float] = defaultdict(float)
68
+ for journey in self.journeys_ or ():
69
+ if not journey.converted:
70
+ continue
71
+ weights = self._weights(journey)
72
+ if not weights:
73
+ raise AttributionError("A converted journey produced no channel weights.")
74
+ total = sum(weight for _, weight in weights)
75
+ if total <= 0:
76
+ raise AttributionError("Attribution weights must sum to a positive value.")
77
+ for channel, weight in weights:
78
+ normalized = weight / total
79
+ value_credit[channel] += normalized * journey.conversion_value
80
+ conversion_credit[channel] += normalized
81
+ return make_attribution_result(self.name, value_credit, conversion_credit)
82
+
83
+ @abstractmethod
84
+ def _weights(self, journey: Journey) -> list[tuple[str, float]]:
85
+ """Return potentially repeated channel-weight pairs for one journey."""
86
+
87
+
88
+ def make_attribution_result(
89
+ model: str,
90
+ value_credit: dict[str, float],
91
+ conversion_credit: dict[str, float],
92
+ ) -> AttributionResult:
93
+ """Build a consistently ordered public result table."""
94
+
95
+ total_value = sum(value_credit.values())
96
+ rows = [
97
+ {
98
+ "channel": channel,
99
+ "attribution_credit": credit,
100
+ "share": credit / total_value if total_value else 0.0,
101
+ "conversions": conversion_credit.get(channel, 0.0),
102
+ }
103
+ for channel, credit in value_credit.items()
104
+ ]
105
+ data = pd.DataFrame(rows, columns=ATTRIBUTION_COLUMNS)
106
+ if not data.empty:
107
+ data = data.sort_values(
108
+ ["attribution_credit", "channel"], ascending=[False, True], ignore_index=True
109
+ )
110
+ return AttributionResult(model=model, data=data)
@@ -0,0 +1,5 @@
1
+ """First-touch attribution compatibility module."""
2
+
3
+ from .rules import FirstTouchAttribution
4
+
5
+ __all__ = ["FirstTouchAttribution"]
@@ -0,0 +1,5 @@
1
+ """Last-touch attribution compatibility module."""
2
+
3
+ from .rules import LastTouchAttribution
4
+
5
+ __all__ = ["LastTouchAttribution"]
@@ -0,0 +1,5 @@
1
+ """Linear attribution compatibility module."""
2
+
3
+ from .rules import LinearAttribution
4
+
5
+ __all__ = ["LinearAttribution"]
@@ -0,0 +1,291 @@
1
+ """Absorbing Markov-chain attribution using channel removal effects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from collections.abc import Iterable
7
+ from itertools import pairwise
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from ..journeys import Journey, JourneyCollection, Touchpoint
13
+ from .base import AttributionError, AttributionModel, AttributionResult, make_attribution_result
14
+
15
+ START = "START"
16
+ CONVERSION = "CONVERSION"
17
+ NULL = "NULL"
18
+ _RESERVED_STATES = {START, CONVERSION, NULL}
19
+
20
+
21
+ class MarkovAttribution(AttributionModel):
22
+ """Attribute conversions using first-order absorbing removal effects.
23
+
24
+ Each journey becomes ``START -> touches -> CONVERSION|NULL``. Transition
25
+ probabilities are row-normalized observed edge counts. Removing a channel
26
+ deletes its row and column; each predecessor reconnects across its remaining
27
+ observed outgoing edges by row normalization. A predecessor with no remaining
28
+ edge is sent to NULL. The removal effect is
29
+ ``max(0, 1 - removed_probability / baseline_probability)``. Effects are
30
+ normalized to allocate all observed conversion value and count.
31
+
32
+ ``max_channels`` is an explicit state-space cap. When more channels are
33
+ observed, the long tail is collapsed into ``__OTHER__``. That aggregation is
34
+ logged on ``aggregated_channels_`` and is never applied silently.
35
+ """
36
+
37
+ name = "markov"
38
+
39
+ def __init__(
40
+ self,
41
+ max_channels: int | None = None,
42
+ removal_strategy: str = "reconnect",
43
+ ) -> None:
44
+ super().__init__()
45
+ if max_channels is not None and max_channels <= 0:
46
+ raise ValueError("max_channels must be a positive integer when provided.")
47
+ if removal_strategy not in {"reconnect", "redirect_to_null"}:
48
+ raise ValueError(
49
+ "removal_strategy must be 'reconnect' or 'redirect_to_null'."
50
+ )
51
+ self.max_channels = max_channels
52
+ self.removal_strategy = removal_strategy
53
+ self.aggregated_channels_: tuple[str, ...] = ()
54
+ self.transition_counts_: pd.DataFrame | None = None
55
+ self.transition_probabilities_: pd.DataFrame | None = None
56
+ self.baseline_conversion_probability_: float | None = None
57
+ self.removed_conversion_probabilities_: dict[str, float] = {}
58
+ self.removal_effects_: dict[str, float] = {}
59
+
60
+ @property
61
+ def transition_counts(self) -> pd.DataFrame | None:
62
+ """Observed transition counts after fitting."""
63
+
64
+ return self.transition_counts_
65
+
66
+ @property
67
+ def transition_probabilities(self) -> pd.DataFrame | None:
68
+ """Observed row-normalized transition probabilities after fitting."""
69
+
70
+ return self.transition_probabilities_
71
+
72
+ @property
73
+ def baseline_conversion_probability(self) -> float | None:
74
+ """Probability of eventual conversion from START."""
75
+
76
+ return self.baseline_conversion_probability_
77
+
78
+ @property
79
+ def removal_effects(self) -> dict[str, float]:
80
+ """Per-channel unnormalized removal effects."""
81
+
82
+ return dict(self.removal_effects_)
83
+
84
+ @property
85
+ def removed_conversion_probabilities(self) -> dict[str, float]:
86
+ """Conversion probability after removing each channel."""
87
+
88
+ return dict(self.removed_conversion_probabilities_)
89
+
90
+ @property
91
+ def transition_matrix(self) -> pd.DataFrame | None:
92
+ """Alias for the fitted transition probability matrix."""
93
+
94
+ return self.transition_probabilities_
95
+
96
+ def fit(
97
+ self, journeys: JourneyCollection | Iterable[Journey]
98
+ ) -> MarkovAttribution:
99
+ """Estimate transitions, baseline absorption, and channel removals."""
100
+
101
+ super().fit(journeys)
102
+ records = self.journeys_ or ()
103
+ if self.max_channels is not None:
104
+ records, self.aggregated_channels_ = _collapse_rare_channels(
105
+ records, self.max_channels
106
+ )
107
+ self.journeys_ = records
108
+ else:
109
+ self.aggregated_channels_ = ()
110
+ channels = sorted({channel for journey in records for channel in journey.channels})
111
+ reserved = _RESERVED_STATES.intersection(channels)
112
+ if reserved:
113
+ raise AttributionError(f"Channel names conflict with Markov states: {sorted(reserved)}")
114
+ paths = [_path(journey) for journey in records]
115
+ self.transition_counts_, self.transition_probabilities_ = _transition_tables(paths)
116
+ names = list(self.transition_probabilities_.index)
117
+ matrix = self.transition_probabilities_.to_numpy(dtype=float)
118
+ baseline = _conversion_probability_matrix(matrix, names)
119
+ self.baseline_conversion_probability_ = baseline
120
+ self.removed_conversion_probabilities_ = {}
121
+ self.removal_effects_ = {}
122
+ for channel in channels:
123
+ reduced, reduced_names = _remove_channel_matrix(
124
+ matrix,
125
+ names,
126
+ channel,
127
+ strategy=self.removal_strategy,
128
+ )
129
+ probability = _conversion_probability_matrix(reduced, reduced_names)
130
+ self.removed_conversion_probabilities_[channel] = probability
131
+ self.removal_effects_[channel] = (
132
+ max(0.0, 1.0 - probability / baseline) if baseline > 0 else 0.0
133
+ )
134
+ return self
135
+
136
+ def _attribute_fitted(self) -> AttributionResult:
137
+ records = self.journeys_ or ()
138
+ channels = sorted(self.removal_effects_)
139
+ total_value = sum(j.conversion_value for j in records if j.converted)
140
+ total_conversions = float(sum(j.converted for j in records))
141
+ effect_total = sum(self.removal_effects_.values())
142
+ # Degenerate chains (for example, all paths convert regardless of channel)
143
+ # carry no identifiable removal signal. Equal shares are explicit and stable.
144
+ shares = {
145
+ channel: (
146
+ self.removal_effects_[channel] / effect_total
147
+ if effect_total > 0
148
+ else 1.0 / len(channels)
149
+ )
150
+ for channel in channels
151
+ }
152
+ value_credit = {channel: share * total_value for channel, share in shares.items()}
153
+ conversion_credit = {
154
+ channel: share * total_conversions for channel, share in shares.items()
155
+ }
156
+ return make_attribution_result(self.name, value_credit, conversion_credit)
157
+
158
+ def _weights(self, journey: Journey) -> list[tuple[str, float]]:
159
+ """Provide a valid local fallback; fitted Markov attribution uses removal effects."""
160
+
161
+ return [(channel, 1.0) for channel in journey.channels]
162
+
163
+
164
+ # Descriptive alias commonly used by callers.
165
+ MarkovChainAttribution = MarkovAttribution
166
+
167
+
168
+ def _path(journey: Journey) -> tuple[str, ...]:
169
+ outcome = CONVERSION if journey.converted else NULL
170
+ return (START, *journey.channels, outcome)
171
+
172
+
173
+ def _transition_tables(paths: list[tuple[str, ...]]) -> tuple[pd.DataFrame, pd.DataFrame]:
174
+ counts: Counter[tuple[str, str]] = Counter()
175
+ states = {START, CONVERSION, NULL}
176
+ for path in paths:
177
+ states.update(path)
178
+ counts.update(pairwise(path))
179
+ ordered = [START, *sorted(states - _RESERVED_STATES), CONVERSION, NULL]
180
+ table = pd.DataFrame(0.0, index=ordered, columns=ordered)
181
+ for (source, target), count in counts.items():
182
+ table.loc[source, target] = float(count)
183
+ totals = table.sum(axis=1)
184
+ probabilities = table.div(totals.replace(0, 1), axis=0)
185
+ # Outcome states are absorbing by model definition, rather than observations.
186
+ probabilities.loc[CONVERSION, CONVERSION] = 1.0
187
+ probabilities.loc[NULL, NULL] = 1.0
188
+ return table, probabilities
189
+
190
+
191
+ def _collapse_rare_channels(
192
+ records: tuple[Journey, ...], max_channels: int
193
+ ) -> tuple[tuple[Journey, ...], tuple[str, ...]]:
194
+ counts: Counter[str] = Counter()
195
+ for journey in records:
196
+ counts.update(journey.channels)
197
+ if len(counts) <= max_channels:
198
+ return records, ()
199
+ keep = {channel for channel, _ in counts.most_common(max_channels - 1)}
200
+ other = "__OTHER__"
201
+ if other in counts and other not in keep:
202
+ keep.add(other)
203
+ collapsed: list[Journey] = []
204
+ aggregated = tuple(sorted(set(counts) - keep))
205
+ for journey in records:
206
+ mapped = tuple(channel if channel in keep else other for channel in journey.channels)
207
+ collapsed.append(
208
+ Journey(
209
+ user_id=journey.user_id,
210
+ touchpoints=tuple(
211
+ Touchpoint(channel=channel, timestamp=touch.timestamp)
212
+ for touch, channel in zip(journey.touchpoints, mapped, strict=True)
213
+ ),
214
+ converted=journey.converted,
215
+ conversion_time=journey.conversion_time,
216
+ conversion_value=journey.conversion_value,
217
+ )
218
+ )
219
+ return tuple(collapsed), aggregated
220
+
221
+
222
+ def _conversion_probability(probabilities: pd.DataFrame) -> float:
223
+ names = list(probabilities.index)
224
+ return _conversion_probability_matrix(probabilities.to_numpy(dtype=float), names)
225
+
226
+
227
+ def _conversion_probability_matrix(matrix: np.ndarray, names: list[str]) -> float:
228
+ if START not in names:
229
+ return 0.0
230
+ absorbing = {names.index(CONVERSION), names.index(NULL)}
231
+ transient = [index for index in range(len(names)) if index not in absorbing]
232
+ start_pos = transient.index(names.index(START))
233
+ conv = names.index(CONVERSION)
234
+ q = matrix[np.ix_(transient, transient)]
235
+ to_conversion = matrix[transient, conv]
236
+ identity = np.eye(len(transient))
237
+ try:
238
+ absorption = np.linalg.solve(identity - q, to_conversion)
239
+ except np.linalg.LinAlgError:
240
+ absorption = np.linalg.lstsq(identity - q, to_conversion, rcond=None)[0]
241
+ return float(np.clip(absorption[start_pos], 0.0, 1.0))
242
+
243
+
244
+ def _remove_channel_matrix(
245
+ matrix: np.ndarray,
246
+ names: list[str],
247
+ channel: str,
248
+ *,
249
+ strategy: str = "reconnect",
250
+ ) -> tuple[np.ndarray, list[str]]:
251
+ drop = names.index(channel)
252
+ keep = [index for index in range(len(names)) if index != drop]
253
+ reduced = matrix[np.ix_(keep, keep)].copy()
254
+ reduced_names = [names[index] for index in keep]
255
+ conv = reduced_names.index(CONVERSION)
256
+ null = reduced_names.index(NULL)
257
+ for index, name in enumerate(reduced_names):
258
+ if name in {CONVERSION, NULL}:
259
+ continue
260
+ if strategy == "redirect_to_null":
261
+ original_index = keep[index]
262
+ reduced[index, null] += matrix[original_index, drop]
263
+ total = float(reduced[index].sum())
264
+ if total > 0 and not np.isclose(total, 1.0):
265
+ reduced[index] /= total
266
+ else:
267
+ total = float(reduced[index].sum())
268
+ if total > 0:
269
+ reduced[index] /= total
270
+ if float(reduced[index].sum()) == 0:
271
+ reduced[index, null] = 1.0
272
+ reduced[conv] = 0.0
273
+ reduced[conv, conv] = 1.0
274
+ reduced[null] = 0.0
275
+ reduced[null, null] = 1.0
276
+ return reduced, reduced_names
277
+
278
+
279
+ def _remove_channel(
280
+ probabilities: pd.DataFrame,
281
+ channel: str,
282
+ *,
283
+ strategy: str = "reconnect",
284
+ ) -> pd.DataFrame:
285
+ reduced, names = _remove_channel_matrix(
286
+ probabilities.to_numpy(dtype=float),
287
+ list(probabilities.index),
288
+ channel,
289
+ strategy=strategy,
290
+ )
291
+ return pd.DataFrame(reduced, index=names, columns=names)
@@ -0,0 +1,33 @@
1
+ """Deterministic baseline attribution models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..journeys import Journey
6
+ from .base import AttributionModel
7
+
8
+
9
+ class FirstTouchAttribution(AttributionModel):
10
+ """Assign all credit to the first touch."""
11
+
12
+ name = "first_touch"
13
+
14
+ def _weights(self, journey: Journey) -> list[tuple[str, float]]:
15
+ return [(journey.first_channel, 1.0)]
16
+
17
+
18
+ class LastTouchAttribution(AttributionModel):
19
+ """Assign all credit to the last touch."""
20
+
21
+ name = "last_touch"
22
+
23
+ def _weights(self, journey: Journey) -> list[tuple[str, float]]:
24
+ return [(journey.last_channel, 1.0)]
25
+
26
+
27
+ class LinearAttribution(AttributionModel):
28
+ """Assign equal credit to every touch, including repeated channels."""
29
+
30
+ name = "linear"
31
+
32
+ def _weights(self, journey: Journey) -> list[tuple[str, float]]:
33
+ return [(channel, 1.0) for channel in journey.channels]