{"slug": "turn-any-csv-into-an-executive-report-with-python-and-ai", "title": "Turn Any CSV into an Executive Report with Python and AI", "summary": "A Python pipeline using Claude Opus 4.8 and Pandas turns a raw sales CSV into an executive report, cleaning the data, computing metrics, and drafting insights. For a 45-row product_sales.csv dataset, the pipeline found gross revenue of $12,975, refunds of -$4,875, net revenue of $8,100, and a 38% refund rate, after dropping 3 non-completed transactions. The approach demonstrates automating report generation while keeping human oversight of the analysis.", "body_md": "# Turn Any CSV into an Executive Report with Python and AI\n\nLearn to implement a repeatable pipeline that cleans a CSV, finds the story, and writes it up.\n\n## # Moving Beyond Analysis By Hand\n\nEvery analyst has done this by hand. A CSV lands in your inbox, someone asks \"so how did we do,\" and you spend an afternoon cleaning columns, building a few charts, and typing up what they mean.\n\nWe can automate most of that. In this walkthrough, we build a small pipeline in Python that takes a raw sales CSV, cleans it, runs the numbers, draws the charts, and asks an AI to draft the insights. The AI here is ** Claude Opus 4.8**. The model writes the first draft of the narrative in seconds. We still decide what is true.\n\nBefore any of that, the report needs a question. Ours is: how much revenue did we keep over these five weeks, and where did the rest go? Every step below answers a piece of it. Cleaning decides which rows count as money. The aggregates say where and when we lost it. The AI step turns those numbers into a summary an executive will read.\n\nAll the code is below so you can reproduce it, and the steps are the same for almost any dataset:\n\n**CSV → clean → explore → chart → AI insights → recommendations → report**\n\n## # The Data\n\nWe use the `product_sales.csv`\n\nfile, which contains 45 transaction rows. It is a dataset used in ** this interview question**. Keep in mind that in this article we are not solving the original problem. Each row is one payment event: a purchase or a refund, with a country, a date, an amount, and a status.\n\nHere is the raw table preview.\n\n| transaction_id | product_id | country | transaction_date | amount | status | type | original_transaction_id |\n|---|---|---|---|---|---|---|---|\n| TXN-10001 | PROD-2891 | US | 2025-04-15 | 449.99 | completed | purchase | |\n| TXN-10002 | PROD-2891 | US | 2025-04-15 | 449.99 | completed | purchase | |\n| TXN-10003 | PROD-2891 | CA | 2025-04-15 | 449.99 | completed | purchase | |\n| TXN-10004 | PROD-2891 | US | 2025-04-17 | 449.99 | completed | purchase | |\n| … | … | … | … | … | … | … | … |\n| TXN-10045 | PROD-2891 | US | 2025-05-11 | -449.99 | completed | refund | TXN-10044 |\n\nTwo things already stand out. Refunds are stored as negative amounts, and not every row is a completed sale. Both matter for the numbers we report.\n\nWe load it with ** Pandas**:\n\n``` python\nimport pandas as pd\n\ndf = pd.read_csv(\"product_sales.csv\")\n```\n\n## # Cleaning the Data\n\nThe cleaning step decides whether the totals are right. Three rows are pending or failed, so they are not money yet. We fix the types and keep only completed transactions:\n\n```\ndf[\"transaction_date\"] = pd.to_datetime(df[\"transaction_date\"])\ndf[\"amount\"] = pd.to_numeric(df[\"amount\"], errors=\"coerce\")\n\n# Pending and failed transactions are not revenue yet.\nsettled = df[df[\"status\"] == \"completed\"].copy()\nsettled[\"is_refund\"] = settled[\"type\"].eq(\"refund\")\n```\n\nThat drops 3 of 45 rows and leaves 42 completed transactions. If we had reported straight off the raw file, we would have counted a failed payment as a sale.\n\n## # Exploratory Analysis\n\nThe first half of the question: how much did we keep? Separate purchases from refunds and the headline numbers fall out. Refunds are already negative, so net revenue is just the sum of the `amount`\n\ncolumn.\n\n```\ngross = settled.loc[~settled[\"is_refund\"], \"amount\"].sum()\nrefunds = settled.loc[settled[\"is_refund\"], \"amount\"].sum()   # negative\nnet = settled[\"amount\"].sum()\nrefund_rate = -refunds / gross\n\nprint(f\"gross   {gross:,.0f}\")\nprint(f\"refunds {refunds:,.0f}\")\nprint(f\"net     {net:,.0f}\")\nprint(f\"refund rate (value) {refund_rate:.0%}\")\n```\n\nOutput:\n\n```\ngross   12,975\nrefunds -4,875\nnet     8,100\nrefund rate (value) 38%\n```\n\nThat is the whole story in four lines. We sold about $13,000 and gave back $4,875, so net revenue is $8,100. A 38% refund rate is high, and it is the kind of number that never shows up if you only sum positive amounts.\n\nThat gives the total. The second half of the question is where the money went, so we cut the data two ways. By country, to see which markets carry the net figure:\n\n```\nby_country = (settled.groupby(\"country\")[\"amount\"]\n             .agg(net_revenue=\"sum\", transactions=\"count\")\n             .sort_values(\"net_revenue\", ascending=False))\nprint(by_country)\n```\n\n| country | net_revenue | transactions |\n|---|---|---|\n| US | 7199.84 | 38 |\n| GB | 449.99 | 1 |\n| MX | 449.99 | 1 |\n| CA | 0.00 | 2 |\n\nCanada is the surprise. Two completed orders, both refunded, so its net revenue is exactly zero.\n\nThen by week, splitting purchases from refunds:\n\n```\nsettled[\"week\"] = settled[\"transaction_date\"].dt.to_period(\"W\").dt.start_time\nweekly = settled.pivot_table(index=\"week\", columns=\"is_refund\",\n                             values=\"amount\", aggfunc=\"sum\").fillna(0)\nweekly.columns = [\"purchases\", \"refunds\"]\nweekly[\"net\"] = weekly.sum(axis=1)\nprint(weekly)\n```\n\n| week | purchases | refunds | net |\n|---|---|---|---|\n| 2025-04-14 | 4649.89 | -449.99 | 4199.90 |\n| 2025-04-21 | 4274.90 | -299.99 | 3974.91 |\n| 2025-04-28 | 3599.92 | 0.00 | 3599.92 |\n| 2025-05-05 | 449.99 | -1799.96 | -1349.97 |\n| 2025-05-12 | 0.00 | -1424.96 | -1424.96 |\n| 2025-05-19 | 0.00 | -899.98 | -899.98 |\n\nThe first three weeks are net positive. The last three are net negative. Purchases stop in early May while refunds keep coming.\n\nOne more number explains the gap. Using `original_transaction_id`\n\n, we measure how long after a purchase each refund arrives.\n\n```\npurch_dates = (settled.loc[~settled[\"is_refund\"], [\"transaction_id\", \"transaction_date\"]]\n               .set_index(\"transaction_id\")[\"transaction_date\"])\nref = settled[settled[\"is_refund\"]].copy()\nref[\"lag_days\"] = (ref[\"transaction_date\"]\n                   - ref[\"original_transaction_id\"].map(purch_dates)).dt.days\nprint(ref[\"lag_days\"].median())    # 20.0\n```\n\nThe median refund lands 20 days after the sale. April's revenue is still being refunded in May.\n\n## # Building the Charts\n\nWe draw three charts with ** Matplotlib** and save them as PNG files.\n\n``` python\nimport matplotlib.pyplot as plt\n\nweekly[[\"purchases\", \"refunds\"]].plot(kind=\"bar\", color=[\"#2a9d8f\", \"#e76f51\"])\nplt.axhline(0, color=\"black\", linewidth=0.8)\nplt.title(\"Weekly gross purchases vs refunds\")\nplt.tight_layout(); plt.savefig(\"chart_weekly.png\")\n```\n\nThe weekly chart makes the pattern obvious: tall green bars in April, then the refund bars take over in May.\n\n```\nsettled.groupby(\"transaction_date\")[\"amount\"].sum().sort_index().cumsum().plot()\nplt.title(\"Cumulative net revenue over time\")\nplt.tight_layout(); plt.savefig(\"chart_cumulative.png\")\nby_country[\"net_revenue\"].plot(kind=\"barh\", color=\"#2a9d8f\")\nplt.title(\"Net revenue by country\")\nplt.tight_layout(); plt.savefig(\"chart_country.png\")\n```\n\n## # Generating AI Insights\n\nNow we hand the numbers to the model. We build a short text summary of everything we found and print a prompt. You paste that prompt into Claude and paste the reply back into the notebook.\n\n```\nweekly_net = {d.date().isoformat(): round(v) for d, v in weekly[\"net\"].items()}\n\nsummary = f\"\"\"Product sales, {settled['transaction_date'].min().date()} to {settled['transaction_date'].max().date()}.\nGross: ${gross:,.0f}  Refunds: ${-refunds:,.0f}  Net: ${net:,.0f}\nRefund rate by value: {refund_rate:.0%}\nNet revenue by country: {by_country['net_revenue'].round(0).to_dict()}\nWeekly net: {weekly_net}\nMedian days from purchase to refund: 20\"\"\"\n\nprompt = (\n    \"You are a data analyst writing for executives. \"\n    \"Based on this summary, write 3 insights and 3 business \"\n    \"recommendations. Be specific and cautious about small sample size.\\n\\n\"\n    + summary\n)\n\nprint(prompt)\n```\n\nOutput:\n\n```\nYou are a data analyst writing for executives. Based on this summary, write 3 insights and 3 business recommendations. Be specific and cautious about small sample size.\n\nProduct sales, 2025-04-15 to 2025-05-22.\n    Gross: $12,975  Refunds: $4,875  Net: $8,100\n    Refund rate by value: 38%\n    Net revenue by country: {'US': 7200.0, 'GB': 450.0, 'MX': 450.0, 'CA': 0.0}\n    Weekly net: {'2025-04-14': 4200, '2025-04-21': 3975, '2025-04-28': 3600, '2025-05-05': -1350, '2025-05-12': -1425, '2025-05-19': -900}\n    Median days from purchase to refund: 20\n```\n\nThe model only sees the summary numbers. It reasons over clean aggregates, and no row-level data leaves your machine. Here is what Claude Opus 4.8 returned:\n\nThis is where a human has to stay in the loop. The model read the summary well, but it does not know that this is one product across five weeks and 42 rows. The caution about sample size is right, and we would not take any of these numbers to a board meeting without more history.\n\n## # Assembling the Executive Report\n\nThe last step assembles a self-contained `report.html`\n\nfile with the headline metrics as cards, the three charts, and the AI text. The full builder is [here](https://gist.github.com/snfnstratcha/19f23ba8b2c6ce098665ed7985117ef6).\n\nHere is a snapshot of the report:\n\nIf you want to see the full report, download this [HTML file](https://drive.google.com/file/d/1yc7xnKy9AY7uTHa1hE_lZQHZsbkzpYW9/view?usp=sharing) and open it in your browser.\n\n## # Conclusion\n\nThe pipeline is short: clean the data, compute a few honest aggregates, draw three charts, and let the model draft the narrative. The cleaning step and the aggregates decide whether the report is right. The AI saves the hour you would spend writing it up.\n\nClaude Opus 4.8 wrote clear, cautious insights from the summary, and it correctly flagged the small sample. It cannot verify the data or know the business context, so the recommendations are a first draft we edit. Run the companion script on your own CSV, change the column names, and you have a reporting tool you can point at the next file that lands in your inbox.\n\nis a data scientist and in product strategy. He's also an adjunct professor teaching analytics, and is the founder of StrataScratch, a platform helping data scientists prepare for their interviews with real interview questions from top companies. Nate writes on the latest trends in the career market, gives interview advice, shares data science projects, and covers everything SQL.\n\n[Nate Rosidi](https://twitter.com/StrataScratch)", "url": "https://wpnews.pro/news/turn-any-csv-into-an-executive-report-with-python-and-ai", "canonical_source": "https://www.kdnuggets.com/turn-any-csv-into-an-executive-report-with-python-and-ai", "published_at": "2026-08-05 12:00:18+00:00", "updated_at": "2026-08-05 12:49:58.336700+00:00", "lang": "en", "topics": ["artificial-intelligence", "generative-ai", "ai-tools", "developer-tools"], "entities": ["Claude Opus 4.8", "Pandas", "Python"], "alternates": {"html": "https://wpnews.pro/news/turn-any-csv-into-an-executive-report-with-python-and-ai", "markdown": "https://wpnews.pro/news/turn-any-csv-into-an-executive-report-with-python-and-ai.md", "text": "https://wpnews.pro/news/turn-any-csv-into-an-executive-report-with-python-and-ai.txt", "jsonld": "https://wpnews.pro/news/turn-any-csv-into-an-executive-report-with-python-and-ai.jsonld"}}