<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rupak Kulkarni</title>
    <description>The latest articles on DEV Community by Rupak Kulkarni (@rupak_kulkarni_08b45c5846).</description>
    <link>https://dev.to/rupak_kulkarni_08b45c5846</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4187098%2Fe67ac03b-a79a-43b2-98af-e9f4cc25d99f.png</url>
      <title>DEV Community: Rupak Kulkarni</title>
      <link>https://dev.to/rupak_kulkarni_08b45c5846</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rupak_kulkarni_08b45c5846"/>
    <language>en</language>
    <item>
      <title>Building a Retail Media Analytics Pipeline with PySpark: Bronze, Silver, and Gold</title>
      <dc:creator>Rupak Kulkarni</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:41:42 +0000</pubDate>
      <link>https://dev.to/rupak_kulkarni_08b45c5846/building-a-retail-media-analytics-pipeline-with-pyspark-bronze-silver-and-gold-1ffm</link>
      <guid>https://dev.to/rupak_kulkarni_08b45c5846/building-a-retail-media-analytics-pipeline-with-pyspark-bronze-silver-and-gold-1ffm</guid>
      <description>&lt;p&gt;A retail-media dashboard can look straightforward: impressions, clicks, spend, conversions, and revenue by campaign. But the engineering underneath becomes more interesting when those metrics originate from several advertising platforms.&lt;/p&gt;

&lt;p&gt;To explore that problem, I put together a public portfolio project using &lt;strong&gt;PySpark, SQL, and the medallion architecture&lt;/strong&gt;. It works with &lt;strong&gt;simulated&lt;/strong&gt; campaign data representing Google Ads, Meta Ads, Pinterest, and Snapchat—no employer data or confidential datasets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/rk268/retail-media-analytics-pipeline" rel="noopener noreferrer"&gt;Retail Media Analytics Pipeline on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this walkthrough, I'll show how the pipeline is structured, how the KPI layer works, and why data-quality checks matter before aggregating campaigns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: one dashboard, multiple definitions
&lt;/h2&gt;

&lt;p&gt;Suppose an analyst wants to compare campaign performance across platforms. At a minimum, they need a common way to describe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the activity happened&lt;/li&gt;
&lt;li&gt;Which platform and campaign generated it&lt;/li&gt;
&lt;li&gt;Impressions and clicks&lt;/li&gt;
&lt;li&gt;Spend, conversions, and attributed revenue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A shared schema makes the &lt;em&gt;engineering&lt;/em&gt; consistent, but doesn't magically make measurement definitions identical. Attribution windows, conversion events, currency, and reporting time zones can differ by platform. That's why a unified data model is a starting point—not the finish line.&lt;/p&gt;

&lt;p&gt;For this demo, the input is intentionally standardized:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;event_date, platform, campaign_id, campaign_name,
impressions, clicks, spend, conversions, revenue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a production system, I'd first implement adapters for each native platform export or API. Those adapters are a planned extension of this repository, not something I'm claiming is complete today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Bronze → Silver → Gold
&lt;/h2&gt;

&lt;p&gt;The repo follows a familiar medallion pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Simulated platform extracts (CSV)
              |
              v
         BRONZE / raw
              |
              v
        SILVER / clean
     schema + normalization
              |
              v
        GOLD / analytics
   campaign aggregates + KPIs
              |
          SQL / BI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Bronze: preserve what arrived
&lt;/h3&gt;

&lt;p&gt;The sample data lives under &lt;code&gt;data/raw/&lt;/code&gt;. In a production design, Bronze should retain the original source records with ingestion timestamps, batch identifiers, and source metadata. That gives you a reproducible audit trail if a platform changes its schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  Silver: standardize data types and identifiers
&lt;/h3&gt;

&lt;p&gt;The current &lt;code&gt;src/bronze_to_silver.py&lt;/code&gt; implementation selects expected columns and normalizes the records with PySpark. Among other things, it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parses &lt;code&gt;event_date&lt;/code&gt; into a date&lt;/li&gt;
&lt;li&gt;Lowercases and trims the platform identifier&lt;/li&gt;
&lt;li&gt;Trims campaign identifiers and names&lt;/li&gt;
&lt;li&gt;Casts count fields to &lt;code&gt;long&lt;/code&gt; and monetary measures to &lt;code&gt;double&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Removes duplicates by date, platform, and campaign ID&lt;/li&gt;
&lt;li&gt;Filters records that fail selected business rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, the relevant Spark expressions look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;

&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;campaign_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;campaign_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;impressions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;impressions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LongType&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DoubleType&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample pipeline writes partitioned Parquet output. The important next step is to &lt;strong&gt;quarantine rejected rows instead of only filtering them&lt;/strong&gt;, so errors remain inspectable. That's on the project's roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gold: turn observations into usable KPIs
&lt;/h2&gt;

&lt;p&gt;The implementation in &lt;a href="https://github.com/rk268/retail-media-analytics-pipeline/blob/main/src/silver_to_gold.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/silver_to_gold.py&lt;/code&gt;&lt;/a&gt; groups records by &lt;code&gt;platform&lt;/code&gt;, &lt;code&gt;campaign_id&lt;/code&gt;, and &lt;code&gt;campaign_name&lt;/code&gt;, then sums impressions, clicks, spend, conversions, and revenue.&lt;/p&gt;

&lt;p&gt;Ratios should be calculated &lt;strong&gt;after aggregation&lt;/strong&gt;, rather than averaging each input row's rate.&lt;/p&gt;

&lt;p&gt;The code uses a safe-divide expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;safe_divide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numerator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;denominator&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;denominator&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;numerator&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;denominator&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;otherwise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it derives metrics such as:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;KPI&lt;/th&gt;
&lt;th&gt;Calculation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CTR&lt;/td&gt;
&lt;td&gt;Clicks / impressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPC&lt;/td&gt;
&lt;td&gt;Spend / clicks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPM&lt;/td&gt;
&lt;td&gt;Spend × 1,000 / impressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversion rate&lt;/td&gt;
&lt;td&gt;Conversions / clicks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPA&lt;/td&gt;
&lt;td&gt;Spend / conversions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ROAS&lt;/td&gt;
&lt;td&gt;Revenue / spend&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a &lt;strong&gt;hypothetical&lt;/strong&gt; campaign with 10,000 impressions, 500 clicks, $250 of spend, 25 conversions, and $1,000 in attributed revenue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CTR = 5%&lt;/li&gt;
&lt;li&gt;CPC = $0.50&lt;/li&gt;
&lt;li&gt;CPM = $25&lt;/li&gt;
&lt;li&gt;Conversion rate = 5%&lt;/li&gt;
&lt;li&gt;CPA = $10&lt;/li&gt;
&lt;li&gt;ROAS = 4.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This example is illustrative—not a measured marketing result. Also, representing an undefined ratio as &lt;code&gt;0.0&lt;/code&gt; is a conscious implementation choice; for some analytics products, &lt;code&gt;NULL&lt;/code&gt; plus a missing-denominator flag would communicate that distinction more accurately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add data quality before trusting a dashboard
&lt;/h2&gt;

&lt;p&gt;A pipeline can finish successfully and still produce misleading numbers. Imagine a row with negative spend, missing conversions, or more clicks than impressions. If it reaches a dashboard without a warning, someone may interpret those values as business performance.&lt;/p&gt;

&lt;p&gt;The repository includes a reusable &lt;a href="https://github.com/rk268/retail-media-analytics-pipeline/blob/main/src/data_quality.py" rel="noopener noreferrer"&gt;PySpark quality-report module&lt;/a&gt;. It checks for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Missing required columns&lt;/li&gt;
&lt;li&gt;Missing or blank business keys&lt;/li&gt;
&lt;li&gt;Null or negative metric values&lt;/li&gt;
&lt;li&gt;Clicks greater than impressions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rather than silently removing problematic rows, the quality report aggregates the &lt;strong&gt;counts of rule violations&lt;/strong&gt;. A companion function fails the check when violations are present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assert_quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quality_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;asDict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;row_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retail media data-quality gate failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project includes tests with synthetic good and bad records. &lt;strong&gt;One important distinction:&lt;/strong&gt; this reusable gate exists and has tests, but it is not yet wired into every step of the command-line processing flow. Integrating it with an explicit reject/quarantine path is further work.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI is part of the pipeline, too
&lt;/h2&gt;

&lt;p&gt;Tests are useful only if the build environment can run them. The repository's GitHub Actions workflow installs Java 17 and the Python dependencies declared in &lt;code&gt;requirements.txt&lt;/code&gt; before invoking &lt;code&gt;python -m pytest -q&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's a practical lesson for any Spark portfolio or production project: &lt;strong&gt;runtime dependencies and reproducible tests belong in version control&lt;/strong&gt;, alongside transformation code.&lt;/p&gt;

&lt;p&gt;You can review the &lt;a href="https://github.com/rk268/retail-media-analytics-pipeline/blob/main/.github/workflows/ci.yml" rel="noopener noreferrer"&gt;CI workflow&lt;/a&gt; and test cases directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this example does not solve yet
&lt;/h2&gt;

&lt;p&gt;The repository is a compact, public learning project, not a production-grade retail-media measurement platform. Still on the roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Native schema adapters for individual advertising platforms&lt;/li&gt;
&lt;li&gt;Reject/quarantine datasets and richer quality expectations&lt;/li&gt;
&lt;li&gt;Incremental ingestion, idempotent processing, and backfills&lt;/li&gt;
&lt;li&gt;Orchestration and operational monitoring&lt;/li&gt;
&lt;li&gt;Attribution-window harmonization and incrementality examples&lt;/li&gt;
&lt;li&gt;A dashboard-ready semantic layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those details matter. Two platforms can report the same numerical ROAS while using different definitions of a conversion or revenue. Data engineering should make such differences explicit instead of hiding them behind a neat chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;I think of the medallion approach here as a sequence of questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bronze:&lt;/strong&gt; What exactly arrived?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silver:&lt;/strong&gt; Can the data be trusted and compared technically?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gold:&lt;/strong&gt; Which business metrics are ready to consume?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful first version doesn't need to solve every measurement challenge. It needs a clear data model, understandable transformations, honest limitations, and tests that protect against obvious errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explore the code:&lt;/strong&gt; &lt;a href="https://github.com/rk268/retail-media-analytics-pipeline" rel="noopener noreferrer"&gt;github.com/rk268/retail-media-analytics-pipeline&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've built cross-channel reporting pipelines, what has been more challenging in practice: &lt;strong&gt;schema normalization, metric definitions, or attribution differences&lt;/strong&gt;?&lt;/p&gt;

</description>
      <category>pyspark</category>
      <category>dataengineering</category>
      <category>analytics</category>
      <category>python</category>
    </item>
  </channel>
</rss>
