Marketing Attribution Model in Python
Compare 6 attribution models and build a budget reallocation dashboard.
Introduction
30 Second Summary
Every marketing team spends money across multiple channels like search ads, social media, and email. The hard part is figuring out which channel actually convinced a customer to buy. Most companies default to giving all the credit to the last ad a customer clicked, but that ignores every touchpoint that came before it.
In this project, you will build a cross-channel marketing attribution model that compares 6 different approaches to crediting marketing channels for conversions. You will produce data-driven budget reallocation recommendations and visualize everything in an interactive Tableau Public dashboard for executive stakeholders.
What You'll Build
A complete Python attribution analysis pipeline that generates realistic customer journey data, runs 6 attribution models (first-touch, last-touch, linear, time-decay, Markov Chain, and Shapley Value), and exports actionable budget recommendations to a Tableau dashboard.
By the end of this project, you'll have:
- A Python script that generates synthetic multi-channel customer journey data and runs 6 different multi-touch attribution models to compare how each one credits marketing channels.
- A budget reallocation model showing exactly where to shift marketing spend based on data-driven attribution insights instead of the traditional last-click approach.
- A customer segmentation analysis that groups customers by journey complexity and identifies high-value cohorts.
- An interactive Tableau Public dashboard with attribution comparison charts and segmentation views for executive stakeholders.
- Secret Mission: Add a Removal Effect analysis that quantifies exactly how much each channel contributes by simulating what happens when it is removed from all customer journeys.
Are there any prerequisites?
You need basic Python familiarity (variables, functions, loops). No prior marketing analytics experience is required.
Set Up Your Python Environment
Marketing attribution analysis runs statistical models on customer journey data to figure out which channels actually drive conversions. To do that in Python, you need a few specialized libraries.
In this step, you'll create a PyCharm project and install the mta library along with pandas, NumPy, and matplotlib. Once everything is verified, you're ready to start generating data.
In this step, get ready to:
- Create a PyCharm project for your marketing attribution analysis.
- Install the mta library and its dependencies.
- Verify all imports run without errors.
Create a new PyCharm project
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type PyCharm and press Enter to open it.
PyCharm opens to a Welcome screen where you can create or open projects.
- Click New Project.
- In the Name field, replace the default name with marketing-attribution.
- Make sure the Python version dropdown shows Python 3.11 or higher.
- Click Create.
Now you need a Python file to write your analysis in.
- In the Project panel on the left, right-click the marketing-attribution folder.
- Select New, then Python File.
- Type attribution_analysis and press Enter.
You should see attribution_analysis.py open in the editor as an empty file.
Don't see the Project panel?
Click View in the top menu bar, then Tool Windows, then Project to toggle it on.
help me find the Project panel in PyCharm
Install the required libraries
Your analysis depends on four Python libraries that handle attribution modeling, data manipulation, math operations, and chart creation.
- Click View in the top menu bar.
- Select Tool Windows, then Terminal.
- Install all four libraries by running this command:
pip install mta==0.0.8 pandas==3.0.2 numpy==2.4.4 matplotlib==3.10.9
Why mta instead of ChannelAttribution?
Another popular Python library for attribution is ChannelAttribution. It requires Microsoft Visual C++ 14.0+ on Windows because it contains C++ extensions. This can add significant troubleshooting time to setup.
The mta library is pure Python, so it installs cleanly on any platform. It also supports more models than ChannelAttribution, including Shapley Value and time-decay.
The install takes about a minute. You should see Successfully installed messages for each package when it finishes.
Seeing a permission error or install failure?
If you see a permissions error, try adding the --user flag to the install command:
pip install --user mta==0.0.8 pandas==3.0.2 numpy==2.4.4 matplotlib==3.10.9
Make sure the terminal is using the same Python interpreter as your project. Check the bottom-right corner of PyCharm for the interpreter label.
help me fix my pip install error in PyCharm
Verify the installation
Installing a library does not mean it is ready to use in your script. A quick import test confirms everything is wired up correctly.
- In attribution_analysis.py, add the following import statements:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from mta import MTA
What do these imports do?
- pandas as pd loads the data manipulation library. You will use it to create DataFrames and aggregate journey data.
- numpy as np loads the numerical computing library. You will use it for random number generation and array operations.
- matplotlib.pyplot as plt loads the plotting module. You will use it to create attribution comparison charts.
- from mta import MTA imports the main class from the mta library. This single class runs all six attribution models.
- Save attribution_analysis.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Right-click anywhere in the editor and select Run 'attribution_analysis'.
If the script runs without any error messages in the console at the bottom, all four libraries are installed correctly.
Seeing a ModuleNotFoundError?
This means the library was not installed for the Python interpreter PyCharm is using. Check the bottom-right corner of PyCharm for the interpreter name. It should match the one you selected when creating the project.
Try running the pip install command again in the terminal. If the error persists, open Settings (Cmd+, on macOS or Ctrl+Alt+S on Windows). Navigate to Project: marketing-attribution, then Python Interpreter, and verify that the packages appear in the list.
help me fix ModuleNotFoundError in PyCharm
✔️ Awesome, I've got everything!
Double check that you have saved attribution_analysis.py and that it ran without errors.
ⓧ I'd like to double check the full code
Here is the complete attribution_analysis.py so far.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from mta import MTA
Your environment is ready. Next up, you'll generate realistic customer journey data that simulates how real shoppers interact with marketing channels before converting.
Generate Realistic Customer Journey Data
Your Python environment is ready with all the libraries installed. Now it is time to create the data that powers the entire attribution analysis.
Before you can compare attribution models, you need multi-channel customer journey data showing how customers interact with different marketing channels before converting. In this step, you will write a journey generator that simulates 10,000 realistic eCommerce customer paths across 7 channels.
In this step, get ready to:
- Build a journey generator function that simulates realistic multi-channel customer paths.
- Aggregate raw journeys into the format the mta library expects.
- Explore the data to understand first-touch and last-touch channel distributions.
Build a journey generator function
A customer journey is the sequence of marketing channels a person interacts with before they buy something (or leave). For example, someone might click a Display Ad, then search on Google (Organic Search), then finally click a Paid Search ad and convert.
Your generator will simulate these paths using weighted probabilities. Different channels are more likely to appear at different stages of the journey, just like in real life.
Why synthetic data instead of a public dataset?
Public marketing datasets are either massive (the Criteo dataset is 2.4 GB) or locked behind authentication. Generating synthetic data in Python ensures reproducibility and teaches you the exact data schema that the mta library expects.
- In attribution_analysis.py, add the following code below your import statements:
# =============================================================================
# STEP 2: Generate Realistic Customer Journey Data
# =============================================================================
np.random.seed(42)
CHANNELS = [
"Paid Search",
"Organic Search",
"Social Media",
"Email",
"Display Ads",
"Referral",
"Direct",
]
# Weighted probabilities for first touchpoint (awareness channels weighted higher)
FIRST_TOUCH_WEIGHTS = [0.25, 0.20, 0.20, 0.05, 0.15, 0.05, 0.10]
# Weighted probabilities for middle touchpoints (consideration channels)
MIDDLE_TOUCH_WEIGHTS = [0.15, 0.15, 0.20, 0.15, 0.15, 0.10, 0.10]
# Weighted probabilities for last touchpoint (conversion channels weighted higher)
LAST_TOUCH_WEIGHTS = [0.20, 0.15, 0.05, 0.20, 0.05, 0.10, 0.25]
NUM_JOURNEYS = 10000
CONVERSION_RATE = 0.12
def generate_journey():
"""Generate a single customer journey with realistic channel sequence."""
path_length = np.random.choice([1, 2, 3, 4, 5, 6], p=[0.15, 0.25, 0.25, 0.20, 0.10, 0.05])
if path_length == 1:
return [np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS)]
path = []
path.append(np.random.choice(CHANNELS, p=FIRST_TOUCH_WEIGHTS))
for _ in range(path_length - 2):
path.append(np.random.choice(CHANNELS, p=MIDDLE_TOUCH_WEIGHTS))
path.append(np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS))
return path
What does this code do?
np.random.seed(42) locks the random number generator so every run produces the same data. This makes your analysis reproducible.
The three weight arrays reflect how channels behave at different journey stages. Awareness channels like Paid Search and Social Media are weighted higher as first touchpoints. Conversion channels like Direct and Email are weighted higher as last touchpoints.
The generate_journey() function picks a random path length (1 to 6 touchpoints), then selects channels using the appropriate weights for each position. Single-touchpoint journeys use last-touch weights because they represent direct converters.
- Save attribution_analysis.py.
You will not see any output yet. The next chunk calls this function 10,000 times to build the dataset.
Aggregate journeys into the MTA format
The mta library expects journey data in a specific format: a DataFrame with columns path (channels separated by > ), total_conversions, and total_null. Now you will generate all 10,000 journeys and reshape them into this format.
- Add this code below the generate_journey() function to generate 10,000 journeys and build a DataFrame:
# Generate all journeys
journeys = []
for _ in range(NUM_JOURNEYS):
path = generate_journey()
converted = np.random.random() < CONVERSION_RATE
journeys.append({"path": " > ".join(path), "converted": converted})
journeys_df = pd.DataFrame(journeys)
What does this code do?
The loop calls generate_journey() 10,000 times. Each journey gets a random conversion result based on the 12% conversion rate (CONVERSION_RATE = 0.12).
The channel list is joined into a single string with > as the separator, so a journey like ["Social Media", "Email", "Direct"] becomes Social Media > Email > Direct. This is the path format the mta library reads.
- Save attribution_analysis.py.
You will not see any output yet. The next chunk aggregates the journeys and prints a summary.
- Add the following code below journeys_df = pd.DataFrame(journeys) to aggregate journeys and print a summary:
# Aggregate into MTA format
path_stats = journeys_df.groupby("path").agg(
total_conversions=("converted", "sum"),
total_null=("converted", lambda x: len(x) - sum(x)),
).reset_index()
path_stats["total_conversions"] = path_stats["total_conversions"].astype(int)
path_stats["total_null"] = path_stats["total_null"].astype(int)
print("=== Journey Data Summary ===")
print(f"Total unique paths: {len(path_stats)}")
print(f"Total conversions: {path_stats['total_conversions'].sum()}")
print(f"Total non-conversions: {path_stats['total_null'].sum()}")
print(f"\nTop 10 paths by volume:")
print(
path_stats.sort_values("total_conversions", ascending=False)
.head(10)
.to_string(index=False)
)
What does this code do?
groupby("path") groups all identical journeys together. For each unique path, it counts how many converted (total_conversions) and how many did not (total_null).
The .astype(int) calls ensure the counts are whole numbers. The mta library expects integer values in these columns.
- Save attribution_analysis.py and run it by clicking the green play button in the top right of PyCharm, or by pressing Ctrl+Shift+F10 (Windows) or Ctrl+Shift+R (macOS).
In the PyCharm console, you should see a Journey Data Summary showing the total number of unique paths, total conversions (around 1,200), and the top 10 most common paths by conversion count.
Not seeing any output?
- Make sure you saved attribution_analysis.py before running it.
- Check that the code from this substep is placed outside and below the generate_journey() function. If the loop sits inside the function, it will not run automatically.
- Confirm your indentation matches the code above. The for loop and print() statements should have zero indentation (left-aligned).
Help me debug why my attribution_analysis.py is not producing any output when I run it in PyCharm.
Save the raw journey data and explore it
Before feeding this data into attribution models, it is helpful to understand the shape of the journeys. Which channels tend to start customer journeys? Which ones close the deal?
- Add the following code at the bottom of attribution_analysis.py to run an exploratory analysis and save the data to a CSV file:
# Exploratory analysis
all_paths = journeys_df["path"].str.split(" > ")
first_touches = all_paths.str[0].value_counts()
last_touches = all_paths.str[-1].value_counts()
path_lengths = all_paths.str.len().value_counts().sort_index()
print("\n=== First Touchpoint Distribution ===")
print(first_touches.to_string())
print("\n=== Last Touchpoint Distribution ===")
print(last_touches.to_string())
print("\n=== Path Length Distribution ===")
print(path_lengths.to_string())
path_stats.to_csv("journey_data.csv", index=False)
print("\nJourney data saved to journey_data.csv")
What does this code do?
The code splits each path string back into a list, then uses pandas string indexing to grab the first element (str[0]) and last element (str[-1]) of each journey. value_counts() tallies how often each channel appears in that position.
The final line saves the aggregated MTA-format data to journey_data.csv. This CSV is what the attribution models will read in the next step.
- Save attribution_analysis.py and run it again.
You should now see three new sections in the console output: First Touchpoint Distribution, Last Touchpoint Distribution, and Path Length Distribution.
What should I see?
Paid Search and Organic Search should rank highest as first touchpoints, because they carry the heaviest awareness weights. Direct and Email should rank highest as last touchpoints, reflecting their role as closing channels.
This mirrors how real eCommerce journeys work. Customers discover brands through search and social, then convert through direct visits and email campaigns.
- Check that journey_data.csv was created in your marketing-attribution project folder by looking in the Project panel on the left side of PyCharm.
Don't see journey_data.csv?
- Make sure the path_stats.to_csv("journey_data.csv", index=False) line is at the bottom of your file, outside any function.
- Right-click your project folder in the Project panel and select Reload from Disk to refresh the file listing.
Help me find where journey_data.csv was saved
✔️ Awesome, I've got everything!
Double check you have saved attribution_analysis.py and confirmed that journey_data.csv exists in your project folder.
ⓧ I'd like to double check the full code
Here is the complete attribution_analysis.py file so far. Make sure your code matches this exactly.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from mta import MTA
# =============================================================================
# STEP 2: Generate Realistic Customer Journey Data
# =============================================================================
np.random.seed(42)
CHANNELS = [
"Paid Search",
"Organic Search",
"Social Media",
"Email",
"Display Ads",
"Referral",
"Direct",
]
# Weighted probabilities for first touchpoint (awareness channels weighted higher)
FIRST_TOUCH_WEIGHTS = [0.25, 0.20, 0.20, 0.05, 0.15, 0.05, 0.10]
# Weighted probabilities for middle touchpoints (consideration channels)
MIDDLE_TOUCH_WEIGHTS = [0.15, 0.15, 0.20, 0.15, 0.15, 0.10, 0.10]
# Weighted probabilities for last touchpoint (conversion channels weighted higher)
LAST_TOUCH_WEIGHTS = [0.20, 0.15, 0.05, 0.20, 0.05, 0.10, 0.25]
NUM_JOURNEYS = 10000
CONVERSION_RATE = 0.12
def generate_journey():
"""Generate a single customer journey with realistic channel sequence."""
path_length = np.random.choice([1, 2, 3, 4, 5, 6], p=[0.15, 0.25, 0.25, 0.20, 0.10, 0.05])
if path_length == 1:
return [np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS)]
path = []
path.append(np.random.choice(CHANNELS, p=FIRST_TOUCH_WEIGHTS))
for _ in range(path_length - 2):
path.append(np.random.choice(CHANNELS, p=MIDDLE_TOUCH_WEIGHTS))
path.append(np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS))
return path
# Generate all journeys
journeys = []
for _ in range(NUM_JOURNEYS):
path = generate_journey()
converted = np.random.random() < CONVERSION_RATE
journeys.append({"path": " > ".join(path), "converted": converted})
journeys_df = pd.DataFrame(journeys)
# Aggregate into MTA format
path_stats = journeys_df.groupby("path").agg(
total_conversions=("converted", "sum"),
total_null=("converted", lambda x: len(x) - sum(x)),
).reset_index()
path_stats["total_conversions"] = path_stats["total_conversions"].astype(int)
path_stats["total_null"] = path_stats["total_null"].astype(int)
print("=== Journey Data Summary ===")
print(f"Total unique paths: {len(path_stats)}")
print(f"Total conversions: {path_stats['total_conversions'].sum()}")
print(f"Total non-conversions: {path_stats['total_null'].sum()}")
print(f"\nTop 10 paths by volume:")
print(
path_stats.sort_values("total_conversions", ascending=False)
.head(10)
.to_string(index=False)
)
# Exploratory analysis
all_paths = journeys_df["path"].str.split(" > ")
first_touches = all_paths.str[0].value_counts()
last_touches = all_paths.str[-1].value_counts()
path_lengths = all_paths.str.len().value_counts().sort_index()
print("\n=== First Touchpoint Distribution ===")
print(first_touches.to_string())
print("\n=== Last Touchpoint Distribution ===")
print(last_touches.to_string())
print("\n=== Path Length Distribution ===")
print(path_lengths.to_string())
path_stats.to_csv("journey_data.csv", index=False)
print("\nJourney data saved to journey_data.csv")
Your journey data is ready. You have 10,000 simulated customer paths saved to a CSV, and you understand which channels dominate at each stage. Next up, you will feed this data into four heuristic attribution models to see how they each assign credit differently.
Run Heuristic Attribution Models
In Step 2, you generated 10,000 customer journeys and saved them to journey_data.csv. Now it's time to answer the big question: which marketing channels actually drive conversions?
The answer depends entirely on which attribution model you use. In this step, you'll run four heuristic models that each assign credit to channels in a different way. The results will show how dramatically your interpretation of channel value shifts depending on the approach.
In this step, get ready to:
- Initialize the MTA model with your journey data.
- Run first-touch, last-touch, linear, and time-decay attribution models.
- Compare how each model credits your marketing channels differently.
Initialize the MTA object and run first-touch and last-touch models
First-touch attribution gives 100% of the credit to the channel that introduced the customer. Last-touch gives 100% to the channel right before conversion. These are the simplest models, and they're still the most common in practice.
The problem is that they completely ignore every channel in between. A customer might see a Display Ad, click an Email, search on Google, and then convert through Direct. First-touch would credit Display Ads entirely. Last-touch would credit Direct entirely. Both are technically "right" and both are misleading.
- In attribution_analysis.py, scroll to the bottom of the file after the last print statement.
- Add this code to initialize the MTA model and run the first two attribution models:
# =============================================================================
# STEP 3: Run Heuristic Attribution Models
# =============================================================================
# Load journey data into the MTA class
mta_model = MTA(data="journey_data.csv", allow_loops=False, add_timepoints=True)
# Run single-touch attribution models
mta_model.first_touch()
mta_model.last_touch()
What does this code do?
MTA(data="journey_data.csv") loads your journey CSV into the mta library's model object. The allow_loops=False parameter removes consecutive duplicate channels in paths (e.g. "Email > Email" becomes just "Email").
add_timepoints=True auto-generates timestamps for each touchpoint, which is needed because the synthetic data doesn't include real timestamps.
first_touch() and last_touch() each calculate attribution and store the results in mta_model.attribution, a dictionary keyed by model name.
- Save attribution_analysis.py.
- You won't see a change yet. The next chunk adds print statements so you can view the results.
Run linear and time-decay models
Linear attribution splits credit equally across every channel in the journey. Time-decay gives more credit to channels closer to the conversion moment. These models acknowledge that every touchpoint matters, but they disagree on how much.
Why does time-decay weight recent touchpoints higher?
The logic is that a customer who saw a Display Ad three weeks ago and clicked an Email yesterday was more influenced by the Email. The Display Ad helped with awareness, but the Email drove the actual decision. Time-decay captures that recency bias.
- Add this code below the mta_model.last_touch() line to run the remaining models and print all results:
# Run multi-touch attribution models
mta_model.linear(share="proportional")
mta_model.time_decay(count_direction="right")
# Print all heuristic model results
print("\n=== Heuristic Model Results ===")
print(f"First Touch: {mta_model.attribution.get('first_touch', {})}")
print(f"Last Touch: {mta_model.attribution.get('last_touch', {})}")
print(f"Linear: {mta_model.attribution.get('linear', {})}")
print(f"Time Decay: {mta_model.attribution.get('time_decay', {})}")
What does this code do?
- linear(share="proportional") distributes credit proportionally across all channels in each journey. A 4-channel path gives each channel 25% of the conversion credit.
- time_decay(count_direction="right") weights credit toward the right end of the path, meaning channels closer to conversion receive more credit.
- mta_model.attribution.get('first_touch', {}) retrieves the results dictionary for each model. Each dictionary maps channel names to the number of credited conversions.
- Save attribution_analysis.py.
- Run the file by clicking the green play button in PyCharm's toolbar, or by right-clicking the file tab and selecting Run 'attribution_analysis'.
- In the terminal output, scroll down past the journey data summary. You should see a === Heuristic Model Results === section with four dictionaries, each showing channels and their credited conversion counts.
Seeing a FileNotFoundError?
The MTA class is trying to load journey_data.csv from your working directory. Make sure the file exists in your marketing-attribution project folder. If you skipped running the file in Step 2, run it once first to generate the CSV.
Help me fix a FileNotFoundError when loading journey_data.csv into the MTA class in PyCharm.
Compare the heuristic results
Look at the four dictionaries in your output. Each one tells a different story about which channels matter most.
What should I see in the results?
Compare the First Touch and Last Touch dictionaries. Channels like Paid Search and Social Media should have higher values in First Touch because your data weights them as awareness channels. Channels like Direct and Email should dominate Last Touch because they're weighted as conversion channels.
Now compare Linear and Time Decay. Linear spreads credit more evenly, so middle-of-journey channels like Social Media gain visibility. Time Decay shifts credit back toward the conversion end, so it looks more similar to Last Touch but still gives partial credit to earlier touchpoints.
This is exactly why attribution matters. The same data produces four different answers about where to spend your marketing budget. In the next step, you'll add two algorithmic models and build a full comparison table to quantify these differences.
✔️ Awesome, I've got everything!
Great. Double check you've saved attribution_analysis.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete attribution_analysis.py file as it should look at the end of this step.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from mta import MTA
# =============================================================================
# STEP 2: Generate Realistic Customer Journey Data
# =============================================================================
np.random.seed(42)
CHANNELS = [
"Paid Search",
"Organic Search",
"Social Media",
"Email",
"Display Ads",
"Referral",
"Direct",
]
FIRST_TOUCH_WEIGHTS = [0.25, 0.20, 0.20, 0.05, 0.15, 0.05, 0.10]
MIDDLE_TOUCH_WEIGHTS = [0.15, 0.15, 0.20, 0.15, 0.15, 0.10, 0.10]
LAST_TOUCH_WEIGHTS = [0.20, 0.15, 0.05, 0.20, 0.05, 0.10, 0.25]
NUM_JOURNEYS = 10000
CONVERSION_RATE = 0.12
def generate_journey():
"""Generate a single customer journey with realistic channel sequence."""
path_length = np.random.choice([1, 2, 3, 4, 5, 6], p=[0.15, 0.25, 0.25, 0.20, 0.10, 0.05])
if path_length == 1:
return [np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS)]
path = []
path.append(np.random.choice(CHANNELS, p=FIRST_TOUCH_WEIGHTS))
for _ in range(path_length - 2):
path.append(np.random.choice(CHANNELS, p=MIDDLE_TOUCH_WEIGHTS))
path.append(np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS))
return path
journeys = []
for _ in range(NUM_JOURNEYS):
path = generate_journey()
converted = np.random.random() < CONVERSION_RATE
journeys.append({"path": " > ".join(path), "converted": converted})
journeys_df = pd.DataFrame(journeys)
path_stats = journeys_df.groupby("path").agg(
total_conversions=("converted", "sum"),
total_null=("converted", lambda x: len(x) - sum(x)),
).reset_index()
path_stats["total_conversions"] = path_stats["total_conversions"].astype(int)
path_stats["total_null"] = path_stats["total_null"].astype(int)
print("=== Journey Data Summary ===")
print(f"Total unique paths: {len(path_stats)}")
print(f"Total conversions: {path_stats['total_conversions'].sum()}")
print(f"Total non-conversions: {path_stats['total_null'].sum()}")
print(f"\nTop 10 paths by volume:")
print(
path_stats.sort_values("total_conversions", ascending=False)
.head(10)
.to_string(index=False)
)
all_paths = journeys_df["path"].str.split(" > ")
first_touches = all_paths.str[0].value_counts()
last_touches = all_paths.str[-1].value_counts()
path_lengths = all_paths.str.len().value_counts().sort_index()
print("\n=== First Touchpoint Distribution ===")
print(first_touches.to_string())
print("\n=== Last Touchpoint Distribution ===")
print(last_touches.to_string())
print("\n=== Path Length Distribution ===")
print(path_lengths.to_string())
path_stats.to_csv("journey_data.csv", index=False)
print("\nJourney data saved to journey_data.csv")
# =============================================================================
# STEP 3: Run Heuristic Attribution Models
# =============================================================================
mta_model = MTA(data="journey_data.csv", allow_loops=False, add_timepoints=True)
mta_model.first_touch()
mta_model.last_touch()
mta_model.linear(share="proportional")
mta_model.time_decay(count_direction="right")
print("\n=== Heuristic Model Results ===")
print(f"First Touch: {mta_model.attribution.get('first_touch', {})}")
print(f"Last Touch: {mta_model.attribution.get('last_touch', {})}")
print(f"Linear: {mta_model.attribution.get('linear', {})}")
print(f"Time Decay: {mta_model.attribution.get('time_decay', {})}")
You now have four heuristic models showing four different pictures of channel performance. Next up, you'll run two algorithmic models that use math to calculate each channel's true contribution, then build a complete comparison table that quantifies exactly how much each model disagrees.
Run Algorithmic Attribution Models
In Step 3, you ran four heuristic attribution models that each follow a fixed rule for assigning credit. First-touch always credits the first channel, last-touch always credits the last, and so on.
The problem is these rules are arbitrary. They don't learn anything from the actual customer journey data. In this step, you'll run two algorithmic models that calculate each channel's contribution mathematically, then merge all six models into a single comparison table that reveals which channels are overvalued and undervalued.
In this step, get ready to:
- Run Markov Chain and Shapley Value attribution models on your journey data.
- Build a comparison table showing how all 6 models credit each channel differently.
- Calculate which channels are overvalued or undervalued by last-touch attribution.
Run the Markov Chain model
The Markov Chain model treats the customer journey as a series of transitions between channels. It calculates the probability of moving from one channel to the next, then determines each channel's importance by measuring how many conversions would be lost if that channel were removed entirely.
Why algorithmic models instead of just heuristic ones?
Heuristic models follow fixed rules ("credit the first touch" or "split evenly") regardless of what the data says. Algorithmic models learn from the patterns in your actual journey data to calculate each channel's real contribution.
This is why marketing teams are shifting toward data-driven attribution. The difference between heuristic and algorithmic results often reveals thousands of dollars in misallocated budget.
Run the Shapley Value model
The Shapley Value model comes from game theory. It calculates each channel's marginal contribution across every possible combination of channels. Think of it as asking: "If we add this channel to any group of other channels, how much does it improve conversions on average?"
- In attribution_analysis.py, scroll to the bottom of your file and find the last print statement from Step 3:
print(f"Time Decay: {mta_model.attribution.get('time_decay', {})}")
- Add both algorithmic model calls below that line by copying this code:
# =============================================================================
# STEP 4: Run Algorithmic Attribution Models
# =============================================================================
mta_model.markov(sim=False, normalize=True)
mta_model.shapley(normalize=True)
print("\n=== Algorithmic Model Results ===")
print(f"Markov Chain: {mta_model.attribution.get('markov', {})}")
print(f"Shapley Value: {mta_model.attribution.get('shapley', {})}")
What does this code do?
- markov(sim=False, normalize=True) runs the analytical Markov Chain calculation. sim=False means it uses exact math instead of a slower simulation. normalize=True scales the results so they add up to the total number of conversions.
- shapley(normalize=True) runs the Shapley Value model, evaluating every possible coalition of channels to determine each one's marginal contribution.
- Both results are stored in mta_model.attribution under the keys 'markov' and 'shapley', alongside the four heuristic models you ran earlier.
- Save attribution_analysis.py.
- Run the file by clicking the green play button in the top right corner of PyCharm, or by pressing Ctrl+Shift+F10 (Windows) or Ctrl+Shift+R (macOS).
- In the terminal output, you should see an "Algorithmic Model Results" section with Markov Chain and Shapley Value dictionaries listing credited conversions per channel.
Seeing an error when running Markov or Shapley?
- Make sure journey_data.csv exists in your project folder. The MTA object loads data from this file, so it must have been generated in Step 2.
- Check that the new code is placed after the Step 3 print statements, not inside them. Indentation matters.
- If you see a ModuleNotFoundError for mta, re-run the install command from Step 1 in your PyCharm terminal.
Help me debug my Markov or Shapley attribution model error.
Build the complete model comparison table
You now have six sets of attribution results stored in the same mta_model.attribution dictionary. The next step is merging them into a single comparison table so you can see how each channel's credit changes depending on which model you use.
- Add the following code below the Shapley print statement to build the raw comparison DataFrame:
models = ["first_touch", "last_touch", "linear", "time_decay", "markov", "shapley"]
comparison_data = []
for channel in CHANNELS:
row = {"channel": channel}
for model in models:
model_results = mta_model.attribution.get(model, {})
row[model] = round(model_results.get(channel, 0), 2)
comparison_data.append(row)
comparison_df = pd.DataFrame(comparison_data)
What does this code do?
- models is a list of all six attribution model names, matching the keys stored in mta_model.attribution.
- The nested loop walks through every channel and every model, pulling the credited conversions from the attribution dictionary and rounding to 2 decimal places.
- The result is a pandas DataFrame with one row per channel and one column per model.
You won't see a change yet. The next block of code adds percentage columns and prints the final table.
- Add the percentage calculations, the key insight column, and print statements directly below comparison_df = pd.DataFrame(comparison_data):
for model in models:
total = comparison_df[model].sum()
if total > 0:
comparison_df[f"{model}_pct"] = round(comparison_df[model] / total * 100, 1)
comparison_df["markov_vs_last_touch_pct"] = (
comparison_df["markov_pct"] - comparison_df["last_touch_pct"]
)
print("\n=== Complete Attribution Comparison ===")
print(comparison_df[["channel"] + models].to_string(index=False))
print("\n=== Markov vs Last-Touch Delta (% points) ===")
delta_view = comparison_df[["channel", "last_touch_pct", "markov_pct", "markov_vs_last_touch_pct"]]
print(delta_view.sort_values("markov_vs_last_touch_pct", ascending=False).to_string(index=False))
What does this code do?
- The first loop adds a percentage column for each model (e.g. first_touch_pct, markov_pct). Each percentage shows what share of total conversions that channel receives under that model.
- markov_vs_last_touch_pct is the key insight column. A positive number means the channel is undervalued by last-touch attribution. A negative number means it is overvalued.
- delta_view sorts channels by this delta so the most undervalued channels appear first. This is the data that will drive your budget reallocation recommendations in the next step.
- Save attribution_analysis.py and run the file again.
- In the terminal output, scroll down to the "Complete Attribution Comparison" table. You should see all 7 channels with credited conversions under all 6 models.
- Below that, the "Markov vs Last-Touch Delta" table shows which channels gain or lose credit when you switch from last-touch to Markov. Channels at the top are the ones your current last-click reporting is undervaluing.
Seeing a KeyError or empty table?
- A KeyError: 'markov_pct' means the Markov model did not run successfully. Check that mta_model.markov(sim=False, normalize=True) appears before the comparison table code.
- If your table shows all zeros, make sure the CHANNELS list from Step 2 matches the channel names in your journey data exactly (including spaces and capitalization).
- Double check that the percentage loop and the markov_vs_last_touch_pct line are placed after comparison_df = pd.DataFrame(comparison_data), not before it.
Help me debug my attribution comparison table.
✔️ Awesome, I've got everything!
Great. Double check you've saved attribution_analysis.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete attribution_analysis.py file including all code from Steps 2, 3, and 4. Compare it to yours and fix any differences.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from mta import MTA
# =============================================================================
# STEP 2: Generate Realistic Customer Journey Data
# =============================================================================
np.random.seed(42)
CHANNELS = [
"Paid Search",
"Organic Search",
"Social Media",
"Email",
"Display Ads",
"Referral",
"Direct",
]
FIRST_TOUCH_WEIGHTS = [0.25, 0.20, 0.20, 0.05, 0.15, 0.05, 0.10]
MIDDLE_TOUCH_WEIGHTS = [0.15, 0.15, 0.20, 0.15, 0.15, 0.10, 0.10]
LAST_TOUCH_WEIGHTS = [0.20, 0.15, 0.05, 0.20, 0.05, 0.10, 0.25]
NUM_JOURNEYS = 10000
CONVERSION_RATE = 0.12
def generate_journey():
"""Generate a single customer journey with realistic channel sequence."""
path_length = np.random.choice([1, 2, 3, 4, 5, 6], p=[0.15, 0.25, 0.25, 0.20, 0.10, 0.05])
if path_length == 1:
return [np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS)]
path = []
path.append(np.random.choice(CHANNELS, p=FIRST_TOUCH_WEIGHTS))
for _ in range(path_length - 2):
path.append(np.random.choice(CHANNELS, p=MIDDLE_TOUCH_WEIGHTS))
path.append(np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS))
return path
journeys = []
for _ in range(NUM_JOURNEYS):
path = generate_journey()
converted = np.random.random() < CONVERSION_RATE
journeys.append({"path": " > ".join(path), "converted": converted})
journeys_df = pd.DataFrame(journeys)
path_stats = journeys_df.groupby("path").agg(
total_conversions=("converted", "sum"),
total_null=("converted", lambda x: len(x) - sum(x)),
).reset_index()
path_stats["total_conversions"] = path_stats["total_conversions"].astype(int)
path_stats["total_null"] = path_stats["total_null"].astype(int)
print("=== Journey Data Summary ===")
print(f"Total unique paths: {len(path_stats)}")
print(f"Total conversions: {path_stats['total_conversions'].sum()}")
print(f"Total non-conversions: {path_stats['total_null'].sum()}")
print(f"\nTop 10 paths by volume:")
print(
path_stats.sort_values("total_conversions", ascending=False)
.head(10)
.to_string(index=False)
)
all_paths = journeys_df["path"].str.split(" > ")
first_touches = all_paths.str[0].value_counts()
last_touches = all_paths.str[-1].value_counts()
path_lengths = all_paths.str.len().value_counts().sort_index()
print("\n=== First Touchpoint Distribution ===")
print(first_touches.to_string())
print("\n=== Last Touchpoint Distribution ===")
print(last_touches.to_string())
print("\n=== Path Length Distribution ===")
print(path_lengths.to_string())
path_stats.to_csv("journey_data.csv", index=False)
print("\nJourney data saved to journey_data.csv")
# =============================================================================
# STEP 3: Run Heuristic Attribution Models
# =============================================================================
mta_model = MTA(data="journey_data.csv", allow_loops=False, add_timepoints=True)
mta_model.first_touch()
mta_model.last_touch()
mta_model.linear(share="proportional")
mta_model.time_decay(count_direction="right")
print("\n=== Heuristic Model Results ===")
print(f"First Touch: {mta_model.attribution.get('first_touch', {})}")
print(f"Last Touch: {mta_model.attribution.get('last_touch', {})}")
print(f"Linear: {mta_model.attribution.get('linear', {})}")
print(f"Time Decay: {mta_model.attribution.get('time_decay', {})}")
# =============================================================================
# STEP 4: Run Algorithmic Attribution Models
# =============================================================================
mta_model.markov(sim=False, normalize=True)
mta_model.shapley(normalize=True)
print("\n=== Algorithmic Model Results ===")
print(f"Markov Chain: {mta_model.attribution.get('markov', {})}")
print(f"Shapley Value: {mta_model.attribution.get('shapley', {})}")
models = ["first_touch", "last_touch", "linear", "time_decay", "markov", "shapley"]
comparison_data = []
for channel in CHANNELS:
row = {"channel": channel}
for model in models:
model_results = mta_model.attribution.get(model, {})
row[model] = round(model_results.get(channel, 0), 2)
comparison_data.append(row)
comparison_df = pd.DataFrame(comparison_data)
for model in models:
total = comparison_df[model].sum()
if total > 0:
comparison_df[f"{model}_pct"] = round(comparison_df[model] / total * 100, 1)
comparison_df["markov_vs_last_touch_pct"] = (
comparison_df["markov_pct"] - comparison_df["last_touch_pct"]
)
print("\n=== Complete Attribution Comparison ===")
print(comparison_df[["channel"] + models].to_string(index=False))
print("\n=== Markov vs Last-Touch Delta (% points) ===")
delta_view = comparison_df[["channel", "last_touch_pct", "markov_pct", "markov_vs_last_touch_pct"]]
print(delta_view.sort_values("markov_vs_last_touch_pct", ascending=False).to_string(index=False))
You now have a complete picture of how six different attribution models see your marketing channels. The delta column is the smoking gun. It tells you exactly which channels are being shortchanged by last-click reporting. Next up, you'll turn those insights into dollar-amount budget reallocation recommendations and segment your customers by journey complexity.
Build Budget Reallocation Recommendations
You now have six attribution models showing very different answers to the same question: which marketing channels deserve credit for conversions?
The real value of attribution modeling is not the numbers themselves. It is the budget decisions those numbers drive. In this step, you will translate attribution percentages into concrete dollar recommendations, segment customers by journey complexity, and generate visualizations that tell the story to stakeholders.
In this step, get ready to:
- Create a budget reallocation model comparing last-touch vs. Markov-based spending.
- Segment customers into cohorts by journey complexity and lifetime value.
- Export results and generate visualizations for stakeholder presentations.
Create a simulated budget allocation
Marketing teams allocate their budgets based on what they believe each channel contributes. Most companies use last-touch attribution for this, which means the channel that gets the final click before a purchase receives all the budget credit.
You will now model a $100,000 monthly budget allocated two ways: one based on last-touch percentages (the status quo) and one based on Markov Chain percentages (the data-driven recommendation).
- At the bottom of attribution_analysis.py, below the delta print statements, add the following budget allocation code:
# =============================================================================
# STEP 5: Budget Reallocation and Customer Segmentation
# =============================================================================
TOTAL_BUDGET = 100000
comparison_df["current_budget_last_touch"] = round(
comparison_df["last_touch_pct"] / 100 * TOTAL_BUDGET, 2
)
comparison_df["recommended_budget_markov"] = round(
comparison_df["markov_pct"] / 100 * TOTAL_BUDGET, 2
)
What does this code do?
TOTAL_BUDGET = 100000 sets a hypothetical $100,000 monthly marketing budget as a constant.
current_budget_last_touch calculates how much each channel would receive if budget followed last-touch attribution percentages. recommended_budget_markov does the same calculation using Markov attribution percentages instead.
- Save attribution_analysis.py.
You will not see a visible change yet. The next chunk uses these columns to compute the dollar difference between the two approaches.
Calculate the reallocation delta
The reallocation delta is the dollar difference between what a channel currently receives (last-touch) and what it should receive (Markov). Channels with a positive delta are underfunded. Channels with a negative delta are overfunded.
- Directly below the code you just added, add the reallocation calculation and print statement:
comparison_df["budget_reallocation"] = (
comparison_df["recommended_budget_markov"] - comparison_df["current_budget_last_touch"]
)
print("\n=== Budget Reallocation Recommendations ===")
budget_view = comparison_df[
["channel", "current_budget_last_touch", "recommended_budget_markov", "budget_reallocation"]
]
print(budget_view.sort_values("budget_reallocation", ascending=False).to_string(index=False))
What does this code do?
budget_reallocation subtracts the last-touch budget from the Markov budget for each channel. A positive number means that channel deserves more investment. A negative number means it is currently overfunded.
The results are sorted by largest reallocation first, so the most underfunded channels appear at the top. This delta table is the core deliverable: a concrete dollar recommendation for reallocating marketing spend.
- Save attribution_analysis.py and run the file by clicking the green play button in PyCharm (or pressing Ctrl+Shift+F10 on Windows or Ctrl+Shift+R on macOS).
- Scroll down in the terminal output. You should see a table titled === Budget Reallocation Recommendations === showing each channel with its current spend, recommended spend, and dollar difference.
Seeing a KeyError?
If you see KeyError: 'last_touch_pct' or KeyError: 'markov_pct', make sure the percentage columns from Step 4 exist in your comparison_df. These are created by the for model in models loop in your Step 4 code.
Help me debug a KeyError in my budget reallocation code
Why use the Markov model for budget decisions?
Of all six models, Markov Chain attribution is the most common choice for budget reallocation because it uses actual customer journey data to calculate transition probabilities between channels. It accounts for the full path, not just isolated touchpoints.
Shapley Value is equally rigorous but computationally expensive at scale. Markov strikes the best balance between accuracy and practicality for real-world marketing teams.
Segment customers by journey complexity and value
Budget reallocation tells you where to spend. Customer segmentation tells you who you are spending on. Grouping customers by how many touchpoints they need before converting reveals which cohorts are most valuable.
- Below the budget reallocation print statement, add the segmentation function and apply it to your journey data:
# Calculate how many touchpoints each journey has
journeys_df["path_length"] = journeys_df["path"].str.split(" > ").str.len()
def classify_segment(length):
if length <= 2:
return "Direct Converters (1-2 touches)"
elif length <= 4:
return "Considered Buyers (3-4 touches)"
else:
return "Research-Heavy (5+ touches)"
# Assign each journey to a segment based on path length
journeys_df["segment"] = journeys_df["path_length"].apply(classify_segment)
What do these segments represent?
Direct Converters (1-2 touches) are customers who convert quickly with minimal research. Considered Buyers (3-4 touches) engage with multiple channels before purchasing.
Research-Heavy customers (5+ touches) take the longest to convert but often have the highest order values. This is a common pattern in eCommerce: more research leads to bigger purchases.
- Save attribution_analysis.py.
You will not see new output yet. The next chunk aggregates these segments into statistics.
- Below the segmentation code, add the aggregation logic that calculates stats for each segment and exports the results:
# Aggregate conversion stats by segment
segment_stats = journeys_df.groupby("segment").agg(
total_journeys=("converted", "count"),
conversions=("converted", "sum"),
).reset_index()
segment_stats["conversion_rate"] = round(
segment_stats["conversions"] / segment_stats["total_journeys"] * 100, 2
)
# Simulated average order values by segment
segment_value_map = {
"Direct Converters (1-2 touches)": 65,
"Considered Buyers (3-4 touches)": 120,
"Research-Heavy (5+ touches)": 210,
}
segment_stats["avg_order_value"] = segment_stats["segment"].map(segment_value_map)
segment_stats["total_revenue"] = segment_stats["conversions"] * segment_stats["avg_order_value"]
print("\n=== Customer Segments ===")
print(segment_stats.to_string(index=False))
# Export both results to CSV for Tableau
comparison_df.to_csv("attribution_results.csv", index=False)
segment_stats.to_csv("customer_segments.csv", index=False)
print("\nResults exported to attribution_results.csv and customer_segments.csv")
What does this code do?
The groupby("segment") aggregation counts total journeys and conversions for each segment. Then it calculates the conversion rate as a percentage.
segment_value_map assigns simulated average order values to each segment. Research-Heavy customers get $210 per order, compared to $65 for Direct Converters. The total_revenue column multiplies conversions by order value to estimate each segment's total contribution.
Finally, the code exports both the attribution comparison and the segment stats to CSV files. You will use these files in the next step to build your Tableau Public dashboard.
- Save attribution_analysis.py and run the file.
- Scroll to the bottom of the terminal output. You should see a table titled === Customer Segments === with three rows showing each segment's journey count, conversions, conversion rate, average order value, and total revenue.
- Check your project folder. You should see two new files: attribution_results.csv and customer_segments.csv.
Not seeing the CSV files?
The CSV files are saved in the same directory where your script runs. In PyCharm, check the marketing-attribution project root in the left sidebar. If you do not see them, right-click the project folder and select Reload from Disk.
Help me find my exported CSV files
- Now add the visualization code at the bottom of attribution_analysis.py, below the CSV export lines. This creates two charts side by side:
# =============================================================================
# VISUALIZATION: Attribution Model Comparison Bar Chart
# =============================================================================
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
# Chart 1: Attribution comparison across models
x = np.arange(len(CHANNELS))
width = 0.12
for i, model in enumerate(models):
axes[0].bar(x + i * width, comparison_df[model], width, label=model.replace("_", " ").title())
axes[0].set_xlabel("Marketing Channel")
axes[0].set_ylabel("Attributed Conversions")
axes[0].set_title("Attribution by Model")
axes[0].set_xticks(x + width * 2.5)
axes[0].set_xticklabels(CHANNELS, rotation=45, ha="right")
axes[0].legend(fontsize=8)
# Chart 2: Budget reallocation
colors = ["green" if v > 0 else "red" for v in comparison_df["budget_reallocation"]]
axes[1].barh(comparison_df["channel"], comparison_df["budget_reallocation"], color=colors)
axes[1].set_xlabel("Budget Change ($)")
axes[1].set_title("Recommended Budget Reallocation\n(Markov vs Last-Touch)")
axes[1].axvline(x=0, color="black", linewidth=0.5)
plt.tight_layout()
plt.savefig("attribution_analysis.png", dpi=150, bbox_inches="tight")
plt.show()
print("\nVisualization saved to attribution_analysis.png")
What do these charts show?
The left chart is a grouped bar chart comparing how many conversions each channel receives under all six attribution models. This lets you visually spot channels where models disagree the most.
The right chart is a diverging bar chart showing the dollar reallocation for each channel. Green bars extending right mean the channel should receive more budget. Red bars extending left mean the channel is currently overfunded.
- Save attribution_analysis.py and run the file.
- A matplotlib window should pop up showing two charts side by side. The left chart displays grouped bars for each channel across all models. The right chart shows green and red horizontal bars for budget changes.
- Close the chart window. Check your project folder for attribution_analysis.png. The chart is saved there automatically.
Chart window not appearing?
If no chart window appears, PyCharm may be rendering plots inline instead. Check your PyCharm settings under Settings, then Tools, then Python Scientific. Uncheck Show plots in tool window if you prefer a separate popup. Either way, the attribution_analysis.png file is still saved to disk.
Help me get matplotlib charts to display in PyCharm
✔️ Awesome, I've got everything!
Great. Double check you have saved attribution_analysis.py and that you can see attribution_results.csv, customer_segments.csv, and attribution_analysis.png in your project folder.
ⓧ I'd like to double check the full code
Here is the complete attribution_analysis.py file through the end of this step. Compare it against yours line by line.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from mta import MTA
# =============================================================================
# STEP 2: Generate Realistic Customer Journey Data
# =============================================================================
np.random.seed(42)
CHANNELS = [
"Paid Search",
"Organic Search",
"Social Media",
"Email",
"Display Ads",
"Referral",
"Direct",
]
FIRST_TOUCH_WEIGHTS = [0.25, 0.20, 0.20, 0.05, 0.15, 0.05, 0.10]
MIDDLE_TOUCH_WEIGHTS = [0.15, 0.15, 0.20, 0.15, 0.15, 0.10, 0.10]
LAST_TOUCH_WEIGHTS = [0.20, 0.15, 0.05, 0.20, 0.05, 0.10, 0.25]
NUM_JOURNEYS = 10000
CONVERSION_RATE = 0.12
def generate_journey():
"""Generate a single customer journey with realistic channel sequence."""
path_length = np.random.choice([1, 2, 3, 4, 5, 6], p=[0.15, 0.25, 0.25, 0.20, 0.10, 0.05])
if path_length == 1:
return [np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS)]
path = []
path.append(np.random.choice(CHANNELS, p=FIRST_TOUCH_WEIGHTS))
for _ in range(path_length - 2):
path.append(np.random.choice(CHANNELS, p=MIDDLE_TOUCH_WEIGHTS))
path.append(np.random.choice(CHANNELS, p=LAST_TOUCH_WEIGHTS))
return path
journeys = []
for _ in range(NUM_JOURNEYS):
path = generate_journey()
converted = np.random.random() < CONVERSION_RATE
journeys.append({"path": " > ".join(path), "converted": converted})
journeys_df = pd.DataFrame(journeys)
path_stats = journeys_df.groupby("path").agg(
total_conversions=("converted", "sum"),
total_null=("converted", lambda x: len(x) - sum(x)),
).reset_index()
path_stats["total_conversions"] = path_stats["total_conversions"].astype(int)
path_stats["total_null"] = path_stats["total_null"].astype(int)
print("=== Journey Data Summary ===")
print(f"Total unique paths: {len(path_stats)}")
print(f"Total conversions: {path_stats['total_conversions'].sum()}")
print(f"Total non-conversions: {path_stats['total_null'].sum()}")
print(f"\nTop 10 paths by volume:")
print(
path_stats.sort_values("total_conversions", ascending=False)
.head(10)
.to_string(index=False)
)
all_paths = journeys_df["path"].str.split(" > ")
first_touches = all_paths.str[0].value_counts()
last_touches = all_paths.str[-1].value_counts()
path_lengths = all_paths.str.len().value_counts().sort_index()
print("\n=== First Touchpoint Distribution ===")
print(first_touches.to_string())
print("\n=== Last Touchpoint Distribution ===")
print(last_touches.to_string())
print("\n=== Path Length Distribution ===")
print(path_lengths.to_string())
path_stats.to_csv("journey_data.csv", index=False)
print("\nJourney data saved to journey_data.csv")
# =============================================================================
# STEP 3: Run Heuristic Attribution Models
# =============================================================================
mta_model = MTA(data="journey_data.csv", allow_loops=False, add_timepoints=True)
mta_model.first_touch()
mta_model.last_touch()
mta_model.linear(share="proportional")
mta_model.time_decay(count_direction="right")
print("\n=== Heuristic Model Results ===")
print(f"First Touch: {mta_model.attribution.get('first_touch', {})}")
print(f"Last Touch: {mta_model.attribution.get('last_touch', {})}")
print(f"Linear: {mta_model.attribution.get('linear', {})}")
print(f"Time Decay: {mta_model.attribution.get('time_decay', {})}")
# =============================================================================
# STEP 4: Run Algorithmic Attribution Models
# =============================================================================
mta_model.markov(sim=False, normalize=True)
mta_model.shapley(normalize=True)
print("\n=== Algorithmic Model Results ===")
print(f"Markov Chain: {mta_model.attribution.get('markov', {})}")
print(f"Shapley Value: {mta_model.attribution.get('shapley', {})}")
models = ["first_touch", "last_touch", "linear", "time_decay", "markov", "shapley"]
comparison_data = []
for channel in CHANNELS:
row = {"channel": channel}
for model in models:
model_results = mta_model.attribution.get(model, {})
row[model] = round(model_results.get(channel, 0), 2)
comparison_data.append(row)
comparison_df = pd.DataFrame(comparison_data)
for model in models:
total = comparison_df[model].sum()
if total > 0:
comparison_df[f"{model}_pct"] = round(comparison_df[model] / total * 100, 1)
comparison_df["markov_vs_last_touch_pct"] = (
comparison_df["markov_pct"] - comparison_df["last_touch_pct"]
)
print("\n=== Complete Attribution Comparison ===")
print(comparison_df[["channel"] + models].to_string(index=False))
print("\n=== Markov vs Last-Touch Delta (% points) ===")
delta_view = comparison_df[["channel", "last_touch_pct", "markov_pct", "markov_vs_last_touch_pct"]]
print(delta_view.sort_values("markov_vs_last_touch_pct", ascending=False).to_string(index=False))
# =============================================================================
# STEP 5: Budget Reallocation and Customer Segmentation
# =============================================================================
TOTAL_BUDGET = 100000
comparison_df["current_budget_last_touch"] = round(
comparison_df["last_touch_pct"] / 100 * TOTAL_BUDGET, 2
)
comparison_df["recommended_budget_markov"] = round(
comparison_df["markov_pct"] / 100 * TOTAL_BUDGET, 2
)
comparison_df["budget_reallocation"] = (
comparison_df["recommended_budget_markov"] - comparison_df["current_budget_last_touch"]
)
print("\n=== Budget Reallocation Recommendations ===")
budget_view = comparison_df[
["channel", "current_budget_last_touch", "recommended_budget_markov", "budget_reallocation"]
]
print(budget_view.sort_values("budget_reallocation", ascending=False).to_string(index=False))
journeys_df["path_length"] = journeys_df["path"].str.split(" > ").str.len()
def classify_segment(length):
if length <= 2:
return "Direct Converters (1-2 touches)"
elif length <= 4:
return "Considered Buyers (3-4 touches)"
else:
return "Research-Heavy (5+ touches)"
journeys_df["segment"] = journeys_df["path_length"].apply(classify_segment)
segment_stats = journeys_df.groupby("segment").agg(
total_journeys=("converted", "count"),
conversions=("converted", "sum"),
).reset_index()
segment_stats["conversion_rate"] = round(
segment_stats["conversions"] / segment_stats["total_journeys"] * 100, 2
)
segment_value_map = {
"Direct Converters (1-2 touches)": 65,
"Considered Buyers (3-4 touches)": 120,
"Research-Heavy (5+ touches)": 210,
}
segment_stats["avg_order_value"] = segment_stats["segment"].map(segment_value_map)
segment_stats["total_revenue"] = segment_stats["conversions"] * segment_stats["avg_order_value"]
print("\n=== Customer Segments ===")
print(segment_stats.to_string(index=False))
comparison_df.to_csv("attribution_results.csv", index=False)
segment_stats.to_csv("customer_segments.csv", index=False)
print("\nResults exported to attribution_results.csv and customer_segments.csv")
# =============================================================================
# VISUALIZATION: Attribution Model Comparison Bar Chart
# =============================================================================
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
x = np.arange(len(CHANNELS))
width = 0.12
for i, model in enumerate(models):
axes[0].bar(x + i * width, comparison_df[model], width, label=model.replace("_", " ").title())
axes[0].set_xlabel("Marketing Channel")
axes[0].set_ylabel("Attributed Conversions")
axes[0].set_title("Attribution by Model")
axes[0].set_xticks(x + width * 2.5)
axes[0].set_xticklabels(CHANNELS, rotation=45, ha="right")
axes[0].legend(fontsize=8)
colors = ["green" if v > 0 else "red" for v in comparison_df["budget_reallocation"]]
axes[1].barh(comparison_df["channel"], comparison_df["budget_reallocation"], color=colors)
axes[1].set_xlabel("Budget Change ($)")
axes[1].set_title("Recommended Budget Reallocation\n(Markov vs Last-Touch)")
axes[1].axvline(x=0, color="black", linewidth=0.5)
plt.tight_layout()
plt.savefig("attribution_analysis.png", dpi=150, bbox_inches="tight")
plt.show()
print("\nVisualization saved to attribution_analysis.png")
Your attribution analysis is complete. You have a budget reallocation model, customer segments, and visualizations ready to go. Next up, you will bring all of this data into Tableau Public to build an interactive dashboard that tells the full story to stakeholders.
Build the Tableau Public Dashboard
Your Python analysis is complete. You have six attribution models compared, budget reallocation recommendations calculated, and customer segments identified.
Raw CSV files don't convince a CMO to shift budget. An interactive dashboard does. In this step, you'll import your results into Tableau Public and build three visualizations that tell the attribution story visually.
In this step, get ready to:
- Upload your attribution data into Tableau Public and build a grouped bar chart comparing all 6 models.
- Create a diverging bar chart that shows where marketing budget should shift.
- Add a customer segmentation view and publish the finished dashboard to your Tableau Public profile.
Import data into Tableau Public
Tableau Public is a free platform for creating and sharing interactive data visualizations. You'll use the web authoring mode to build your dashboard directly in the browser.
- Open your browser and go to public.tableau.com.
- Sign in to your Tableau Public account. If you don't have one, click Sign Up to create a free account.
- Click My Profile in the top navigation bar.
- Click Create a Viz.
The Connect to Data dialog appears. This is where you upload your CSV files.
- Click the Files tab in the Connect to Data dialog.
- Upload attribution_results.csv from your marketing-attribution project folder.
- Click Create Extract to load the data.
The Data Source page shows a preview of your data. You should see 7 rows (one per channel) with columns for each attribution model, percentage columns, and budget reallocation columns.
- Verify that Tableau detected numeric columns correctly. Each model column (First Touch, Last Touch, etc.) should show a # icon, meaning Tableau recognizes them as numbers.
- Click Sheet 1 at the bottom of the screen to enter the worksheet view.
What is Tableau Public?
Tableau Public is the free edition of Tableau, a widely used business intelligence tool. It only supports extracted data sources (not live connections), and everything you publish is public.
For a portfolio piece like this attribution dashboard, public visibility is actually a benefit. Recruiters and hiring managers can interact with your dashboard directly from a link.
Now you'll build the grouped bar chart that compares how each attribution model credits the 7 marketing channels. Tableau uses two special fields for this: Measure Names (which holds the names of your numeric columns) and Measure Values (which holds the actual numbers).
- In the Data pane on the left, drag Channel to the Columns shelf at the top.
- Drag Measure Names to the Columns shelf, placing it to the right of Channel.
- Drag Measure Values to the Rows shelf.
Tableau generates a bar chart showing every numeric column. You only want the 6 attribution model columns, so you need to filter out the budget and percentage columns.
- Find the Measure Names filter card on the right side of the workspace. Click the dropdown arrow and select Edit Filter.
- Click None to deselect all measures.
- Check only these 6 boxes: First Touch, Last Touch, Linear, Time Decay, Markov, and Shapley.
- Click OK.
- Drag Measure Names to Color on the Marks card. This gives each attribution model a different color.
- Rename this sheet by double-clicking the Sheet 1 tab at the bottom and typing Attribution by Model.
You should see a grouped bar chart with 7 channel clusters, each containing 6 colored bars. Each bar represents how many conversions that model credits to that channel.
Seeing all bars the same color?
Make sure you dragged Measure Names to the Color tile on the Marks card. If you placed it somewhere else, the bars won't be color-coded by model.
If you see too many bars per channel, check the Measure Names filter. Only the 6 model columns should be selected.
Help me fix my grouped bar chart in Tableau.
Add the budget reallocation view
The grouped bar chart shows the numbers, but the budget reallocation chart tells the story. A diverging bar chart makes it instantly clear which channels should get more money and which should get less.
- Click the New Worksheet icon (the small tab with a plus sign) at the bottom of the screen.
- Drag Budget Reallocation from the Measures section of the Data pane to the Columns shelf.
- Drag Channel to the Rows shelf.
You should see a horizontal bar chart where bars extend right (positive reallocation) or left (negative reallocation) from a zero baseline. Channels that the Markov model says deserve more budget go right. Channels that are overvalued by last-touch go left.
To make the positive and negative values visually distinct, you need to color-code the bars.
- Right-click in an empty area of the Data pane and select Create Calculated Field.
- Name the field Budget Direction.
- Enter this formula:
IF SUM([Budget Reallocation]) >= 0 THEN "Increase" ELSE "Decrease" END
What does this formula do?
This calculated field checks whether each channel's budget reallocation is positive or negative. It labels positive values as "Increase" and negative values as "Decrease". Tableau uses this label to assign different colors to each direction.
- Click OK to save the calculated field.
- Drag Budget Direction from the Data pane to Color on the Marks card.
The bars are now split into two colors. Customize these to match the green/red convention that executive audiences expect.
- Click Color on the Marks card, then click Edit Colors.
- Select Increase and choose a green color.
- Select Decrease and choose a red color.
- Click OK.
- Rename this sheet to Budget Reallocation by double-clicking the tab at the bottom.
Your diverging bar chart should now show green bars extending right for channels that deserve more budget and red bars extending left for channels that are currently overfunded.
Why a diverging bar chart?
This chart is the core deliverable for the "reallocated spend model" that marketing analyst roles demand. A stakeholder can glance at it and immediately see which channels need more investment and which are draining budget.
The green-right, red-left pattern is a universal visual convention. No legend needed for the audience to understand the message.
Bars are all the same color?
Check that your calculated field formula uses SUM([Budget Reallocation]) with the square brackets around the field name. If Tableau shows an error in the formula editor, the field name may not match exactly.
Make sure you dragged Budget Direction to the Color tile on the Marks card, not to the Rows or Columns shelf.
Help me fix the colors on my diverging bar chart.
Add customer segmentation and publish
The final sheet adds a customer segmentation perspective. This shows stakeholders how different types of buyers behave and where the highest-value customers come from.
- Click the Data Source tab at the bottom-left of the screen to return to the data connection page.
- Click Add next to Connections in the left pane to add a new data source.
- Upload customer_segments.csv from your project folder.
- Click Create Extract to load the data.
You should see 3 rows in the preview, one for each customer segment.
- Click the New Worksheet icon at the bottom of the screen.
- In the Data pane, make sure customer_segments is selected as the active data source. If not, click the data source dropdown at the top of the Data pane and select it.
- Drag Segment to the Rows shelf.
- Drag Conversion Rate to the Columns shelf.
- Drag Avg Order Value to the Columns shelf, placing it to the right of Conversion Rate.
Tableau creates side-by-side bars showing both metrics for each customer segment. This lets stakeholders compare how conversion rates and order values differ across buyer types.
- Rename this sheet to Customer Segments by double-clicking the tab at the bottom.
Why show conversion rate and order value together?
Research-Heavy customers (5+ touchpoints) have the highest average order value. Direct Converters (1-2 touchpoints) convert more often but spend less per order. Showing both metrics side by side helps a marketing team decide where to invest in nurturing longer journeys.
Now combine all three sheets into a single dashboard layout.
- Click the New Dashboard icon at the bottom of the screen (the icon with a grid and a plus sign, next to the New Worksheet icon).
- In the Sheets section on the left, drag Attribution by Model onto the dashboard canvas.
- Drag Budget Reallocation below the first sheet on the canvas.
- Drag Customer Segments below or beside the Budget Reallocation sheet.
- Add a title by dragging a Text object from the Objects section on the left onto the top of the dashboard.
- Type Cross-Channel Attribution Analysis: Budget Reallocation Recommendations as the dashboard title.
- Click OK.
Your dashboard now has all three views arranged under a clear title. Time to publish.
- Click File in the top menu bar.
- Click Save.
- Enter Cross-Channel Attribution Analysis as the workbook name.
- Click Save to publish the dashboard to your Tableau Public profile.
Tableau saves and publishes the workbook. You should see your dashboard live on your Tableau Public profile page with all three sheets interactive.
Dashboard looks cramped or overlapping?
Try changing the dashboard size. In the Dashboard pane on the left, look for the Size section. Set it to Automatic so it adapts to the viewer's screen.
You can also rearrange sheets by clicking and dragging them within the canvas. Use the layout containers (horizontal and vertical) from the Objects section to control how sheets stack.
Help me improve my Tableau dashboard layout.
Secret mission
Add Removal Effect Analysis
Your Markov model already estimates each channel's contribution. But what if you could prove it by simulating the removal of each channel entirely? Build a removal effect analysis that quantifies exactly how critical each channel is to your conversion pipeline.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free Tableau Public dashboard, so there are no ongoing costs.
Resources you used:
- PyCharm project marketing-attribution with attribution_analysis.py.
- Generated CSV files: journey_data.csv, attribution_results.csv, and customer_segments.csv.
- Visualization file attribution_analysis.png.
- Python packages installed via pip: mta, pandas, numpy, matplotlib.
- Tableau Public dashboard published to your profile.
Keep everything running
No action needed. Choose this if you are still actively building on your attribution analysis or plan to extend the dashboard.
- Your marketing-attribution project folder stays on your Desktop, ready to open in PyCharm anytime.
- Your Tableau Public dashboard remains live on your profile for anyone to view.
- The installed Python packages stay available in your environment for future projects.
Pause - I'll come back to this later
Free up disk space while keeping your project files intact. You can pick up right where you left off later.
- Close PyCharm to free up memory.
- Your project folder, CSV files, and Python script remain on your Desktop for when you return.
- Your Tableau Public dashboard stays published. No action needed there.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Delete the marketing-attribution folder from your Desktop. This removes attribution_analysis.py, all CSV files, and the PNG visualization.
- Uninstall the Python packages by opening a terminal and running:
pip uninstall mta pandas numpy matplotlib -y
- To delete your Tableau Public dashboard, log in to your Tableau Public profile. Click on the dashboard, select the three-dot menu, and choose Delete.
Nice Work!
Nice Work!
You just built a complete cross-channel marketing attribution analysis from scratch. This is exactly the kind of data-driven channel audit that marketing teams use to make real budget decisions.
Here's what you learned:
- Compared 6 attribution models (first-touch, last-touch, linear, time-decay, Markov Chain, and Shapley Value) to reveal which marketing channels are overvalued and undervalued by traditional last-click attribution.
- Built a data-driven budget reallocation model showing exactly where to shift marketing spend based on Markov attribution insights versus the last-touch baseline.
- Created an interactive Tableau Public dashboard with attribution comparison charts and customer segmentation views that communicate findings to executive stakeholders.
- Quantified each channel's true importance using removal effect analysis to simulate what happens when a channel is completely removed from all customer journeys.
Ready to quiz yourself?