{ "cells": [ { "cell_type": "markdown", "id": "a188ac4f", "metadata": {}, "source": [ "# ๐ŸŽฌ Market Basket Analysis: StreamFlix Viewing Patterns\n", "## Association Rules Lab โ€” Python Implementation\n", "\n", "**Course:** Data Analytics, Data Mining, Predictive Analytics \n", "**Topic:** Association Rules & Market Basket Analysis \n", "**Dataset:** StreamFlix Viewing Data (500 users, 15 shows) \n", "**Tools:** Python, pandas, mlxtend \n", "\n", "---\n", "\n", "### ๐ŸŽฏ Lab Objectives\n", "\n", "By the end of this lab, you will be able to:\n", "1. Transform transactional data into the format required for association rules mining\n", "2. Apply the Apriori algorithm to discover frequent itemsets\n", "3. Generate and interpret association rules (support, confidence, lift)\n", "4. Filter and rank rules to identify actionable business recommendations\n", "5. Visualize association patterns for stakeholder communication\n", "\n", "### ๐Ÿ“‹ Business Scenario\n", "\n", "> *You're a data analyst at StreamFlix. Your product team wants to improve the recommendation engine. They've asked you to analyze viewing patterns across 500 users and identify which shows are commonly watched together. Your findings will directly inform the \"Because You Watched...\" feature.*\n" ] }, { "cell_type": "markdown", "id": "1bdc17c1", "metadata": {}, "source": [ "---\n", "## Part 1: Setup & Data Loading\n", "\n", "First, let's install and import the libraries we need. The key library is **mlxtend** (machine learning extensions), which provides efficient implementations of the Apriori algorithm." ] }, { "cell_type": "code", "execution_count": null, "id": "ff5b70a6", "metadata": {}, "outputs": [], "source": [ "# Install mlxtend if needed (uncomment the line below if running for the first time)\n", "# !pip install mlxtend\n", "\n", "!pip install mlxtend\n", "import pandas as pd\n", "import numpy as np\n", "from mlxtend.frequent_patterns import apriori, association_rules\n", "from mlxtend.preprocessing import TransactionEncoder\n", "import matplotlib.pyplot as plt\n", "import matplotlib\n", "matplotlib.rcParams['figure.figsize'] = (12, 6)\n", "matplotlib.rcParams['font.size'] = 11\n", "import warnings\n", "warnings.filterwarnings('ignore')\n", "\n", "print('Libraries loaded successfully! โœ…')" ] }, { "cell_type": "markdown", "id": "7f169a62", "metadata": {}, "source": [ "### Load the Data\n", "\n", "Our data is in **transactional format** โ€” each row represents one user-show pair. This is the most common format for real-world transaction data.\n", "\n", "| user_id | show_title |\n", "|---------|------------|\n", "| 1 | Wednesday |\n", "| 1 | Stranger Things |\n", "| 1 | Dark |\n", "| 2 | Squid Game |\n", "| 2 | Money Heist |" ] }, { "cell_type": "code", "execution_count": null, "id": "f11de4d8", "metadata": {}, "outputs": [], "source": [ "# Load the transactional data\n", "df = pd.read_csv('streamflix_viewing_data.csv')\n", "\n", "print(f'Dataset shape: {df.shape}')\n", "print(f'Total viewing records: {len(df):,}')\n", "print(f'Unique users: {df[\"user_id\"].nunique()}')\n", "print(f'Unique shows: {df[\"show_title\"].nunique()}')\n", "print()\n", "print('First 10 rows:')\n", "df.head(10)" ] }, { "cell_type": "markdown", "id": "fa511926", "metadata": {}, "source": [ "### Quick Exploration\n", "\n", "Before running any algorithms, let's understand our data. **Good analysts explore before they model.**" ] }, { "cell_type": "code", "execution_count": null, "id": "353088f3", "metadata": {}, "outputs": [], "source": [ "# How popular is each show?\n", "show_popularity = df.groupby('show_title')['user_id'].nunique().sort_values(ascending=True)\n", "\n", "fig, ax = plt.subplots(figsize=(10, 7))\n", "colors = ['#e50914' if v > 200 else '#2196f3' if v > 100 else '#90a4ae' for v in show_popularity.values]\n", "show_popularity.plot(kind='barh', color=colors, ax=ax, edgecolor='white', linewidth=0.5)\n", "ax.set_xlabel('Number of Users Who Watched', fontsize=12)\n", "ax.set_title('StreamFlix Show Popularity\\n(Red = 200+ users, Blue = 100-200, Gray = <100)', fontsize=14, fontweight='bold')\n", "ax.spines['top'].set_visible(False)\n", "ax.spines['right'].set_visible(False)\n", "\n", "# Add count labels\n", "for i, (val, name) in enumerate(zip(show_popularity.values, show_popularity.index)):\n", " ax.text(val + 3, i, f'{val} ({val/500*100:.0f}%)', va='center', fontsize=9)\n", "\n", "plt.tight_layout()\n", "plt.show()\n", "\n", "print('\\n๐Ÿ’ก KEY INSIGHT: Notice which shows are very popular.')\n", "print(' High-popularity shows will appear in many rules, but that does NOT mean the rules are interesting.')\n", "print(' This is exactly why we need LIFT โ€” to separate real patterns from popularity effects.')" ] }, { "cell_type": "code", "execution_count": null, "id": "edb45bab", "metadata": {}, "outputs": [], "source": [ "# How many shows does each user watch?\n", "shows_per_user = df.groupby('user_id')['show_title'].count()\n", "\n", "fig, ax = plt.subplots(figsize=(8, 4))\n", "shows_per_user.hist(bins=range(1, 16), color='#2196f3', edgecolor='white', ax=ax)\n", "ax.set_xlabel('Number of Shows Watched', fontsize=12)\n", "ax.set_ylabel('Number of Users', fontsize=12)\n", "ax.set_title('Distribution of Shows Per User', fontsize=14, fontweight='bold')\n", "ax.axvline(shows_per_user.mean(), color='#e50914', linestyle='--', linewidth=2, label=f'Mean: {shows_per_user.mean():.1f}')\n", "ax.legend(fontsize=11)\n", "ax.spines['top'].set_visible(False)\n", "ax.spines['right'].set_visible(False)\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "ec32dc31", "metadata": {}, "source": [ "---\n", "## Part 2: Data Transformation\n", "\n", "The Apriori algorithm needs data in a **one-hot encoded** format (also called a binary matrix). Each row = one user, each column = one show, values are True/False.\n", "\n", "This is the most important data prep step. Get this wrong, and your rules will be meaningless.\n", "\n", "> **๐Ÿ”‘ Key Concept:** We're converting from *transactional* format (long) to *basket* format (wide). This is the same transformation whether you're analyzing grocery baskets, viewing histories, or medical records." ] }, { "cell_type": "code", "execution_count": null, "id": "23824c2f", "metadata": {}, "outputs": [], "source": [ "# Method 1: Using TransactionEncoder (works for any transactional data)\n", "# Group shows by user into lists\n", "baskets = df.groupby('user_id')['show_title'].apply(list).tolist()\n", "\n", "print(f'Number of baskets (users): {len(baskets)}')\n", "print(f'\\nExample basket (User 1): {baskets[0]}')\n", "print(f'Example basket (User 2): {baskets[1]}')" ] }, { "cell_type": "code", "execution_count": null, "id": "b055d08e", "metadata": {}, "outputs": [], "source": [ "# Transform to one-hot encoded DataFrame\n", "te = TransactionEncoder()\n", "te_array = te.fit(baskets).transform(baskets)\n", "basket_df = pd.DataFrame(te_array, columns=te.columns_)\n", "\n", "print(f'Binary matrix shape: {basket_df.shape}')\n", "print(f'(500 users ร— {basket_df.shape[1]} shows)\\n')\n", "basket_df.head()" ] }, { "cell_type": "markdown", "id": "413c7bb2", "metadata": {}, "source": [ "### โœ… Checkpoint\n", "\n", "Before moving on, verify your data looks right:\n", "- Each row should represent ONE user\n", "- Each column should be a show title\n", "- Values should be True/False (or 1/0)\n", "- The number of rows should equal the number of unique users (500)\n", "\n", "**Common mistake:** If you see more than 500 rows, you didn't aggregate properly. Go back and check your groupby." ] }, { "cell_type": "markdown", "id": "675e4621", "metadata": {}, "source": [ "---\n", "## Part 3: Finding Frequent Itemsets with Apriori\n", "\n", "Now for the main event. The **Apriori algorithm** finds all combinations of items (itemsets) that appear frequently enough in the data.\n", "\n", "We set a **minimum support threshold** โ€” only itemsets that appear in at least this percentage of all baskets will be kept.\n", "\n", "> **๐Ÿ’ก Think of it this way:** If we set min_support = 0.05, we're saying *\"Only show me combinations that at least 5% of users share.\"* Too low = too many rules (noise). Too high = miss niche patterns.\n", "\n", "### Choosing Minimum Support\n", "\n", "| min_support | Meaning | Use When |\n", "|---|---|---|\n", "| 0.01 (1%) | Very permissive | Large datasets, looking for niche patterns |\n", "| 0.05 (5%) | Balanced | Medium datasets, general exploration |\n", "| 0.10 (10%) | Conservative | Small datasets, only strong patterns |" ] }, { "cell_type": "code", "execution_count": null, "id": "11e32497", "metadata": {}, "outputs": [], "source": [ "# Run Apriori โ€” find frequent itemsets\n", "# Start with min_support=0.05 (items must appear in at least 5% of baskets)\n", "frequent_itemsets = apriori(\n", " basket_df, \n", " min_support=0.05, \n", " use_colnames=True, # Use show names instead of column indices\n", " max_len=3 # Limit to pairs and triples (keeps it manageable)\n", ")\n", "\n", "# Add a column showing how many items are in each set\n", "frequent_itemsets['itemset_size'] = frequent_itemsets['itemsets'].apply(len)\n", "\n", "print(f'Found {len(frequent_itemsets)} frequent itemsets!')\n", "print(f' - Single items: {(frequent_itemsets[\"itemset_size\"]==1).sum()}')\n", "print(f' - Pairs: {(frequent_itemsets[\"itemset_size\"]==2).sum()}')\n", "print(f' - Triples: {(frequent_itemsets[\"itemset_size\"]==3).sum()}')\n", "print()\n", "\n", "# Show the top pairs by support\n", "print('\\nTop 10 Pairs by Support:')\n", "frequent_itemsets[frequent_itemsets['itemset_size']==2].sort_values('support', ascending=False).head(10)" ] }, { "cell_type": "markdown", "id": "4a1ab028", "metadata": {}, "source": [ "### โš ๏ธ Stop and Think!\n", "\n", "Look at the top pairs by support. Are these genuinely interesting, or are they just popular shows that everyone watches?\n", "\n", "**This is exactly the problem we discussed in class.** High support doesn't mean high value. We need to generate the actual *rules* with confidence and lift to find out." ] }, { "cell_type": "markdown", "id": "c6bb8828", "metadata": {}, "source": [ "---\n", "## Part 4: Generating Association Rules\n", "\n", "Now we convert frequent itemsets into **directional rules** (A โ†’ B) and calculate confidence and lift.\n", "\n", "Remember:\n", "- **Confidence** = P(B | A) โ€” How reliable is this prediction?\n", "- **Lift** = Confidence / Support(B) โ€” Is this pattern real or just popularity?" ] }, { "cell_type": "code", "execution_count": null, "id": "2ee9a1ea", "metadata": {}, "outputs": [], "source": [ "# Generate association rules from frequent itemsets\n", "rules = association_rules(\n", " frequent_itemsets, \n", " metric='lift', # Use lift as the primary metric\n", " min_threshold=1.0, # Only keep rules where lift >= 1.0\n", " num_itemsets=len(frequent_itemsets)\n", ")\n", "\n", "# Clean up the display\n", "rules['antecedents_str'] = rules['antecedents'].apply(lambda x: ', '.join(sorted(x)))\n", "rules['consequents_str'] = rules['consequents'].apply(lambda x: ', '.join(sorted(x)))\n", "rules['rule'] = rules['antecedents_str'] + ' โ†’ ' + rules['consequents_str']\n", "\n", "print(f'Generated {len(rules)} association rules with lift >= 1.0')\n", "print()\n", "\n", "# Show key columns\n", "display_cols = ['rule', 'support', 'confidence', 'lift']\n", "rules[display_cols].sort_values('lift', ascending=False).head(15)" ] }, { "cell_type": "markdown", "id": "4f6da130", "metadata": {}, "source": [ "### ๐Ÿ† The Money Rules โ€” Highest Lift\n", "\n", "These are the rules that represent **genuine patterns** in viewing behavior. High lift means the co-occurrence is much higher than what we'd expect by chance alone." ] }, { "cell_type": "code", "execution_count": null, "id": "9f4c39dc", "metadata": {}, "outputs": [], "source": [ "# Top 10 rules by lift (the genuinely interesting ones)\n", "top_by_lift = rules.nlargest(10, 'lift')[['rule', 'support', 'confidence', 'lift']].reset_index(drop=True)\n", "top_by_lift.index = top_by_lift.index + 1 # Start numbering at 1\n", "top_by_lift.columns = ['Rule', 'Support', 'Confidence', 'Lift']\n", "\n", "# Format for display\n", "top_by_lift['Support'] = top_by_lift['Support'].apply(lambda x: f'{x:.3f}')\n", "top_by_lift['Confidence'] = top_by_lift['Confidence'].apply(lambda x: f'{x:.1%}')\n", "top_by_lift['Lift'] = top_by_lift['Lift'].apply(lambda x: f'{x:.2f}')\n", "\n", "print('๐Ÿ† TOP 10 RULES BY LIFT')\n", "print('=' * 80)\n", "print(top_by_lift.to_string())\n", "print()\n", "print('๐Ÿ’ก These are the rules worth presenting to your product team!')" ] }, { "cell_type": "markdown", "id": "17fbde89", "metadata": {}, "source": [ "### ๐Ÿšจ The Trap Rules โ€” High Confidence, Low Lift\n", "\n", "These rules **look impressive** but aren't genuinely useful. They have high confidence only because the consequent is popular with everyone." ] }, { "cell_type": "code", "execution_count": null, "id": "47d4e5c7", "metadata": {}, "outputs": [], "source": [ "# Find trap rules: high confidence (>50%) but low lift (<1.3)\n", "traps = rules[(rules['confidence'] > 0.5) & (rules['lift'] < 1.3)]\n", "traps = traps.nlargest(10, 'confidence')[['rule', 'support', 'confidence', 'lift']].reset_index(drop=True)\n", "\n", "if len(traps) > 0:\n", " print('๐Ÿšจ TRAP RULES โ€” High Confidence but Low Lift')\n", " print('=' * 80)\n", " for _, row in traps.iterrows():\n", " print(f\" {row['rule']}\")\n", " print(f\" Confidence: {row['confidence']:.1%} | Lift: {row['lift']:.2f} โ† NOT interesting!\")\n", " print()\n", " print('โš ๏ธ These rules have high confidence ONLY because the consequent')\n", " print(' is popular with everyone. The lift near 1.0 tells us there\\'s')\n", " print(' no real association โ€” just popularity.')\n", "else:\n", " print('No obvious trap rules found with current thresholds.')" ] }, { "cell_type": "markdown", "id": "332ec0f9", "metadata": {}, "source": [ "---\n", "## Part 5: Visualization\n", "\n", "Good visualizations help you communicate findings to stakeholders who don't speak \"lift\" and \"confidence.\"" ] }, { "cell_type": "code", "execution_count": null, "id": "90dbd716", "metadata": {}, "outputs": [], "source": [ "# Scatter plot: Support vs Confidence, colored by Lift\n", "fig, ax = plt.subplots(figsize=(12, 7))\n", "\n", "scatter = ax.scatter(\n", " rules['support'], \n", " rules['confidence'],\n", " c=rules['lift'], \n", " cmap='RdYlGn',\n", " s=rules['lift'] * 40, # Size proportional to lift\n", " alpha=0.7,\n", " edgecolors='white',\n", " linewidth=0.5\n", ")\n", "\n", "plt.colorbar(scatter, label='Lift', ax=ax)\n", "ax.set_xlabel('Support (How Common)', fontsize=13)\n", "ax.set_ylabel('Confidence (How Reliable)', fontsize=13)\n", "ax.set_title('Association Rules: Support vs Confidence\\n(Size & Color = Lift โ€” Green = Strong Pattern)', fontsize=14, fontweight='bold')\n", "ax.axhline(y=0.5, color='gray', linestyle='--', alpha=0.4, label='50% Confidence')\n", "ax.spines['top'].set_visible(False)\n", "ax.spines['right'].set_visible(False)\n", "\n", "# Annotate top rules\n", "top3 = rules.nlargest(3, 'lift')\n", "for _, row in top3.iterrows():\n", " ax.annotate(row['rule'], (row['support'], row['confidence']),\n", " textcoords='offset points', xytext=(10, 10), fontsize=8,\n", " arrowprops=dict(arrowstyle='->', color='gray', lw=0.8))\n", "\n", "plt.tight_layout()\n", "plt.show()\n", "\n", "print('\\n๐Ÿ’ก The BEST rules are in the upper-right with GREEN color (high support + high confidence + high lift)')\n", "print(' Rules that are high-confidence but YELLOW/RED are the traps!')" ] }, { "cell_type": "code", "execution_count": null, "id": "00ae2824", "metadata": {}, "outputs": [], "source": [ "# Heatmap of top show co-occurrences\n", "# Create a co-occurrence matrix\n", "cooccurrence = basket_df.astype(int).T.dot(basket_df.astype(int))\n", "np.fill_diagonal(cooccurrence.values, 0) # Remove self-pairs\n", "\n", "fig, ax = plt.subplots(figsize=(12, 10))\n", "im = ax.imshow(cooccurrence, cmap='YlOrRd', aspect='auto')\n", "ax.set_xticks(range(len(cooccurrence.columns)))\n", "ax.set_yticks(range(len(cooccurrence.columns)))\n", "ax.set_xticklabels(cooccurrence.columns, rotation=45, ha='right', fontsize=9)\n", "ax.set_yticklabels(cooccurrence.columns, fontsize=9)\n", "ax.set_title('Show Co-Occurrence Heatmap\\n(Darker = More Users Watch Both)', fontsize=14, fontweight='bold')\n", "plt.colorbar(im, label='Number of Users', ax=ax)\n", "\n", "# Add text annotations for top values\n", "for i in range(len(cooccurrence)):\n", " for j in range(len(cooccurrence)):\n", " val = cooccurrence.iloc[i, j]\n", " if val > 80: # Only annotate high values\n", " ax.text(j, i, str(val), ha='center', va='center', fontsize=7, color='white' if val > 120 else 'black')\n", "\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "df6c60f9", "metadata": {}, "source": [ "---\n", "## Part 6: Business Recommendations (The \"So What?\")\n", "\n", "This is where the analysis becomes valuable. Raw rules mean nothing without business context.\n", "\n", "Let's filter rules that are **actionable** and frame them as product recommendations." ] }, { "cell_type": "code", "execution_count": null, "id": "0bc290fe", "metadata": {}, "outputs": [], "source": [ "# Filter for actionable rules: reasonable support + high lift\n", "actionable = rules[\n", " (rules['support'] >= 0.05) & # At least 5% of users\n", " (rules['confidence'] >= 0.40) & # At least 40% reliable\n", " (rules['lift'] >= 1.5) # Genuinely interesting\n", "].sort_values('lift', ascending=False)\n", "\n", "print(f'\\n๐Ÿ“‹ ACTIONABLE RULES FOR PRODUCT TEAM ({len(actionable)} rules)')\n", "print('=' * 90)\n", "print()\n", "\n", "for i, (_, row) in enumerate(actionable.head(8).iterrows(), 1):\n", " print(f'Rule #{i}: {row[\"rule\"]}')\n", " print(f' Support: {row[\"support\"]:.1%} of users | '\n", " f'Confidence: {row[\"confidence\"]:.1%} | '\n", " f'Lift: {row[\"lift\"]:.2f}')\n", " \n", " # Auto-generate business recommendation\n", " ante = row['antecedents_str']\n", " cons = row['consequents_str']\n", " conf_pct = row['confidence'] * 100\n", " lift_pct = (row['lift'] - 1) * 100\n", " \n", " print(f' ๐Ÿ“Œ Recommendation: Users who watch {ante} are {lift_pct:.0f}% more likely')\n", " print(f' to watch {cons} than the average user. Show \"{cons}\" in their')\n", " print(f' \"Because You Watched {ante}\" carousel.')\n", " print()" ] }, { "cell_type": "markdown", "id": "0deeb8c8", "metadata": {}, "source": [ "---\n", "## Part 7: Experimenting with Parameters\n", "\n", "### ๐Ÿงช Your Turn!\n", "\n", "Try changing the parameters below to see how they affect the results. This is how real analysts tune their models.\n", "\n", "**Experiments to try:**\n", "1. Lower `min_support` to 0.02 โ€” what niche patterns emerge?\n", "2. Raise `min_support` to 0.15 โ€” what's left?\n", "3. Change `min_threshold` for lift โ€” what happens at 2.0 vs 1.0?\n", "4. Increase `max_len` to 4 โ€” any interesting 3-way or 4-way rules?" ] }, { "cell_type": "code", "execution_count": null, "id": "fce26214", "metadata": {}, "outputs": [], "source": [ "# ============================================================\n", "# ๐Ÿงช EXPERIMENT ZONE โ€” Change these values and re-run!\n", "# ============================================================\n", "\n", "EXPERIMENT_MIN_SUPPORT = 0.03 # Try: 0.02, 0.05, 0.10, 0.15\n", "EXPERIMENT_MIN_LIFT = 1.5 # Try: 1.0, 1.5, 2.0, 2.5\n", "EXPERIMENT_MAX_LEN = 3 # Try: 2, 3, 4\n", "\n", "# Run experiment\n", "exp_itemsets = apriori(basket_df, min_support=EXPERIMENT_MIN_SUPPORT, \n", " use_colnames=True, max_len=EXPERIMENT_MAX_LEN)\n", "exp_rules = association_rules(exp_itemsets, metric='lift', \n", " min_threshold=EXPERIMENT_MIN_LIFT,\n", " num_itemsets=len(exp_itemsets))\n", "\n", "exp_rules['rule'] = (exp_rules['antecedents'].apply(lambda x: ', '.join(sorted(x))) + \n", " ' โ†’ ' + \n", " exp_rules['consequents'].apply(lambda x: ', '.join(sorted(x))))\n", "\n", "print(f'Parameters: min_support={EXPERIMENT_MIN_SUPPORT}, min_lift={EXPERIMENT_MIN_LIFT}, max_len={EXPERIMENT_MAX_LEN}')\n", "print(f'Frequent itemsets found: {len(exp_itemsets)}')\n", "print(f'Association rules found: {len(exp_rules)}')\n", "print()\n", "\n", "if len(exp_rules) > 0:\n", " print('Top 10 by Lift:')\n", " print(exp_rules.nlargest(10, 'lift')[['rule','support','confidence','lift']].to_string(index=False))\n", "else:\n", " print('No rules found! Try lowering the thresholds.')" ] }, { "cell_type": "markdown", "id": "d77dd21f", "metadata": {}, "source": [ "---\n", "## Part 8: Summary & Reflection\n", "\n", "### What We Learned\n", "\n", "1. **Data Format Matters:** Association rules require transactional data transformed into a binary matrix\n", "2. **Apriori is Efficient:** It prunes unpopular items early, making large-scale analysis feasible\n", "3. **Support โ‰  Value:** Popular items co-occur naturally โ€” lift tells us what's genuinely interesting\n", "4. **The Confidence Trap:** High confidence + low lift = the consequent is just popular with everyone\n", "5. **Business Context is King:** A rule with lift=3.0 is worthless if you can't act on it\n", "\n", "### ๐Ÿ“ Reflection Questions (Answer in Your Submission)\n", "\n", "1. What was the strongest rule you found? Would you recommend building a feature around it? Why or why not?\n", "2. Give an example of a \"trap rule\" โ€” one with high confidence but low lift. Why is it misleading?\n", "3. How would your analysis change if this data was from a grocery store instead of a streaming platform? What would the \"basket\" be?\n", "4. If StreamFlix updated their catalog tomorrow (new shows, removed shows), would your rules still hold? How would you handle this in a real deployment?\n", "\n", "---\n", "\n", "### ๐Ÿ”— Connection to SAS Viya\n", "\n", "In the SAS Viya Model Studio lab, you'll see how the same analysis can be done through a visual, point-and-click interface โ€” no code required. The thinking is identical; only the tool changes.\n", "\n", "**Key takeaway: The analytical thinking is the same regardless of the tool.** Whether you use Python, SAS, R, or Excel โ€” support is support, lift is lift, and business judgment is what makes the difference." ] }, { "cell_type": "code", "execution_count": null, "id": "b5eea2c0-84a3-4b5c-91e5-3017ab456281", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.10.6" } }, "nbformat": 4, "nbformat_minor": 5 }