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.
- mta_audit/__init__.py +196 -0
- mta_audit/attribution/__init__.py +31 -0
- mta_audit/attribution/base.py +110 -0
- mta_audit/attribution/first_touch.py +5 -0
- mta_audit/attribution/last_touch.py +5 -0
- mta_audit/attribution/linear.py +5 -0
- mta_audit/attribution/markov.py +291 -0
- mta_audit/attribution/rules.py +33 -0
- mta_audit/audit.py +557 -0
- mta_audit/audits/__init__.py +83 -0
- mta_audit/audits/channel_concentration.py +117 -0
- mta_audit/audits/concentration.py +15 -0
- mta_audit/audits/contamination.py +178 -0
- mta_audit/audits/conversion_window.py +243 -0
- mta_audit/audits/data_quality.py +13 -0
- mta_audit/audits/exposure.py +189 -0
- mta_audit/audits/identity.py +39 -0
- mta_audit/audits/leakage.py +267 -0
- mta_audit/audits/model_disagreement.py +152 -0
- mta_audit/audits/model_stability.py +217 -0
- mta_audit/audits/path_quality.py +12 -0
- mta_audit/audits/path_sparsity.py +97 -0
- mta_audit/bootstrap.py +195 -0
- mta_audit/checks.py +476 -0
- mta_audit/core.py +30 -0
- mta_audit/datasets/__init__.py +58 -0
- mta_audit/datasets/criteo.py +331 -0
- mta_audit/datasets/evaluation.py +47 -0
- mta_audit/datasets/external.py +431 -0
- mta_audit/datasets/scenarios.py +149 -0
- mta_audit/datasets/synthetic.py +168 -0
- mta_audit/journeys.py +423 -0
- mta_audit/py.typed +1 -0
- mta_audit/results.py +647 -0
- mta_audit/robustness.py +341 -0
- mta_audit/sampling.py +96 -0
- mta_audit/schema.py +190 -0
- mta_audit/scoring.py +157 -0
- mta_audit/simulation/__init__.py +17 -0
- mta_audit/simulation/corruption.py +450 -0
- mta_audit/utils/__init__.py +10 -0
- mta_audit/utils/validation.py +5 -0
- mta_audit/visualization/__init__.py +19 -0
- mta_audit/visualization/plots.py +128 -0
- mta_audit-0.1.0.dist-info/METADATA +389 -0
- mta_audit-0.1.0.dist-info/RECORD +48 -0
- mta_audit-0.1.0.dist-info/WHEEL +4 -0
- 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,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]
|