--- name: analytics-hand-skill version: "1.0.0" description: "Expert knowledge for AI data analytics -- statistical methods, visualization best practices, pandas reference, and reporting patterns" runtime: prompt_only --- # Data Analytics Expert Knowledge ## pandas Quick Reference ### Data Loading ```python import pandas as pd # CSV df = pd.read_csv('data.csv') df = pd.read_csv('data.csv', parse_dates=['date_col'], index_col='id') # JSON df = pd.read_json('data.json') df = pd.read_json('data.json', orient='records') # Excel df = pd.read_excel('data.xlsx', sheet_name='Sheet1') # From dict df = pd.DataFrame({'col1': [1, 2, 3], 'col2': ['a', 'b', 'c']}) ``` ### Data Inspection ```python df.shape # (rows, columns) df.dtypes # Column types df.info() # Summary including memory usage df.describe() # Statistical summary df.head(10) # First 10 rows df.isnull().sum() # Missing values per column df.duplicated().sum() # Number of duplicate rows df.nunique() # Unique values per column ``` ### Data Cleaning ```python # Handle missing values df.dropna() # Drop rows with any NaN df.fillna(0) # Fill NaN with 0 df.fillna(df.mean()) # Fill with column means df['col'].interpolate() # Interpolate missing values # Remove duplicates df.drop_duplicates() df.drop_duplicates(subset=['col1', 'col2']) # Type conversion df['col'] = df['col'].astype(int) df['date'] = pd.to_datetime(df['date']) df['cat'] = df['cat'].astype('category') # Outlier removal (IQR method) Q1 = df['col'].quantile(0.25) Q3 = df['col'].quantile(0.75) IQR = Q3 - Q1 df = df[(df['col'] >= Q1 - 1.5*IQR) & (df['col'] <= Q3 + 1.5*IQR)] ``` ### Aggregation & Grouping ```python # Group by df.groupby('category').agg({'value': ['mean', 'sum', 'count']}) # Pivot table pd.pivot_table(df, values='value', index='row_cat', columns='col_cat', aggfunc='mean') # Cross tabulation pd.crosstab(df['cat1'], df['cat2']) # Rolling statistics df['rolling_mean'] = df['value'].rolling(window=7).mean() # Percentage change df['pct_change'] = df['value'].pct_change() ``` ### Time Series ```python # Set datetime index df.set_index('date', inplace=True) # Resample df.resample('W').mean() # Weekly average df.resample('M').sum() # Monthly sum df.resample('Q').count() # Quarterly count # Date range pd.date_range(start='2025-01-01', periods=30, freq='D') # Shift/Lag df['prev_value'] = df['value'].shift(1) df['next_value'] = df['value'].shift(-1) ``` --- ## Visualization Best Practices ### matplotlib + seaborn Reference ```python import matplotlib matplotlib.use('Agg') # Non-interactive backend import matplotlib.pyplot as plt import seaborn as sns # Set style sns.set_theme(style='whitegrid') plt.rcParams['figure.figsize'] = (10, 6) ``` ### Chart Selection Guide | Data Type | Question | Chart Type | |-----------|----------|------------| | Categorical | Comparison | Bar chart | | Categorical | Proportion | Pie chart (if <6 categories) | | Numerical | Distribution | Histogram / Box plot | | Two numerical | Relationship | Scatter plot | | Time series | Trend | Line chart | | Matrix | Correlation | Heatmap | | Categories + values | Comparison | Grouped bar / Stacked bar | | Geographical | Location | Map / Choropleth | ### Chart Templates **Bar Chart**: ```python fig, ax = plt.subplots(figsize=(10, 6)) data = df['category'].value_counts() data.plot(kind='bar', ax=ax, color='steelblue') ax.set_title('Distribution by Category', fontsize=14, fontweight='bold') ax.set_xlabel('Category') ax.set_ylabel('Count') plt.xticks(rotation=45, ha='right') plt.tight_layout() plt.savefig('bar_chart.png', dpi=150, bbox_inches='tight') plt.close() ``` **Line Chart (Time Series)**: ```python fig, ax = plt.subplots(figsize=(12, 6)) ax.plot(df.index, df['value'], linewidth=2, color='steelblue') ax.fill_between(df.index, df['value'], alpha=0.1, color='steelblue') ax.set_title('Trend Over Time', fontsize=14, fontweight='bold') ax.set_xlabel('Date') ax.set_ylabel('Value') plt.tight_layout() plt.savefig('line_chart.png', dpi=150, bbox_inches='tight') plt.close() ``` **Correlation Heatmap**: ```python fig, ax = plt.subplots(figsize=(10, 8)) corr = df.select_dtypes(include='number').corr() sns.heatmap(corr, annot=True, fmt='.2f', cmap='RdBu_r', center=0, ax=ax) ax.set_title('Correlation Matrix', fontsize=14, fontweight='bold') plt.tight_layout() plt.savefig('heatmap.png', dpi=150, bbox_inches='tight') plt.close() ``` **Scatter Plot**: ```python fig, ax = plt.subplots(figsize=(10, 6)) ax.scatter(df['x'], df['y'], alpha=0.6, edgecolors='black', linewidth=0.5) ax.set_title('X vs Y', fontsize=14, fontweight='bold') ax.set_xlabel('X Variable') ax.set_ylabel('Y Variable') plt.tight_layout() plt.savefig('scatter.png', dpi=150, bbox_inches='tight') plt.close() ``` ### Visualization Do's and Don'ts **Do**: - Start y-axis at 0 for bar charts - Use consistent colors across related charts - Label axes clearly with units - Add titles that describe the insight, not just the data - Use appropriate scales (log scale for exponential data) **Don't**: - Use 3D charts (distorts perception) - Use more than 6-7 colors in one chart - Truncate axes to exaggerate differences - Use pie charts for more than 5 categories - Add unnecessary chart junk (borders, backgrounds, grids) --- ## Statistical Methods ### Descriptive Statistics | Measure | pandas | Purpose | |---------|--------|---------| | Mean | `df['col'].mean()` | Central tendency | | Median | `df['col'].median()` | Robust central tendency | | Std Dev | `df['col'].std()` | Variability | | Skewness | `df['col'].skew()` | Distribution symmetry | | Kurtosis | `df['col'].kurtosis()` | Distribution tails | | Percentiles | `df['col'].quantile([0.25, 0.5, 0.75])` | Distribution spread | ### Correlation Analysis ```python # Pearson correlation (linear) df['col1'].corr(df['col2']) # Spearman correlation (monotonic) df['col1'].corr(df['col2'], method='spearman') # Full correlation matrix df.select_dtypes(include='number').corr() ``` Interpretation: - |r| > 0.7: Strong correlation - 0.4 < |r| < 0.7: Moderate correlation - |r| < 0.4: Weak correlation - Correlation != Causation ### Hypothesis Testing (scipy) ```python from scipy import stats # T-test (compare two group means) t_stat, p_value = stats.ttest_ind(group1, group2) # Chi-squared test (categorical independence) chi2, p_value, dof, expected = stats.chi2_contingency(contingency_table) # Significance: p < 0.05 is commonly used threshold # Mann-Whitney U test (non-parametric alternative to t-test) u_stat, p_value = stats.mannwhitneyu(group1, group2, alternative='two-sided') # One-way ANOVA (compare 3+ group means) f_stat, p_value = stats.f_oneway(group1, group2, group3) # Normality check (determines which test to use) shapiro_stat, p_value = stats.shapiro(data) # p > 0.05 means normal ``` ### Statistical Significance Decision Guide **Test selection flowchart:** | Data Situation | Normal Distribution? | Test to Use | |---------------|---------------------|-------------| | Compare 2 group means | Yes | Independent t-test (`ttest_ind`) | | Compare 2 group means | No | Mann-Whitney U (`mannwhitneyu`) | | Compare 3+ group means | Yes | One-way ANOVA (`f_oneway`) | | Compare 3+ group means | No | Kruskal-Wallis (`kruskal`) | | Compare paired samples | Yes | Paired t-test (`ttest_rel`) | | Compare paired samples | No | Wilcoxon signed-rank (`wilcoxon`) | | Test categorical independence | N/A | Chi-squared (`chi2_contingency`) | | Test correlation | Yes | Pearson (`pearsonr`) | | Test correlation | No | Spearman (`spearmanr`) | **P-value interpretation:** | p-value | Interpretation | Action | |---------|---------------|--------| | p < 0.01 | Strong evidence against null hypothesis | Report as statistically significant | | 0.01 ≤ p < 0.05 | Moderate evidence | Report as significant with caveat | | 0.05 ≤ p < 0.10 | Weak evidence | Report as marginally significant | | p ≥ 0.10 | Insufficient evidence | Do not claim significance | **Practical significance — always report effect size:** ```python # Cohen's d for comparing two means def cohens_d(group1, group2): n1, n2 = len(group1), len(group2) var1, var2 = group1.var(), group2.var() pooled_std = ((n1 - 1) * var1 + (n2 - 1) * var2) / (n1 + n2 - 2) return (group1.mean() - group2.mean()) / (pooled_std ** 0.5) # Interpretation: |d| < 0.2 = negligible, 0.2-0.5 = small, 0.5-0.8 = medium, > 0.8 = large ``` **Sample size awareness:** - n < 30: Use non-parametric tests; results are exploratory - 30 ≤ n < 100: Parametric tests OK if normality holds; moderate confidence - n ≥ 100: Central Limit Theorem applies; high confidence in parametric tests - Always report sample size alongside p-values **Confidence threshold mapping:** | Setting | p-value threshold | Minimum effect size | Minimum sample size | |---------|------------------|--------------------|--------------------| | High | p < 0.01 | Cohen's d ≥ 0.5 | n ≥ 100 | | Medium | p < 0.05 | Cohen's d ≥ 0.3 | n ≥ 30 | | Low | p < 0.10 | Any | Any | --- ## Report Structure Best Practices ### CRISP-DM Framework 1. **Business Understanding**: What question are we answering? 2. **Data Understanding**: What data do we have? Quality? 3. **Data Preparation**: Cleaning, transformation, feature engineering 4. **Modeling**: Statistical analysis, ML models 5. **Evaluation**: Are results valid and useful? 6. **Deployment**: Reports, dashboards, recommendations ### Insight Hierarchy ``` Level 1: What happened (descriptive) "Revenue increased 15% last quarter" Level 2: Why it happened (diagnostic) "Revenue increase driven by 30% growth in enterprise segment" Level 3: What will happen (predictive) "Based on current trends, Q2 revenue projected at $X" Level 4: What to do (prescriptive) "Invest in enterprise sales team to capitalize on growth trajectory" ``` ### Data Quality Assessment Template ``` | Dimension | Score | Details | |-----------|-------|---------| | Completeness | 85% | 15% missing values in 'email' column | | Accuracy | High | Validated against source system | | Consistency | Medium | Date formats vary across sources | | Timeliness | Current | Data refreshed daily | | Uniqueness | 99% | 1% duplicate records found | ``` --- ## Worked Examples ### Example 1: E-commerce Sales Analysis **Goal**: Analyze 12 months of order data to identify revenue drivers, customer segments, and growth trends. #### Step 1 — Load and clean ```python import pandas as pd import numpy as np df = pd.read_csv('orders.csv', parse_dates=['order_date']) # Quick audit print(f"Rows: {len(df):,} Columns: {df.shape[1]}") print(df.isnull().sum()[df.isnull().sum() > 0]) # Clean df = df.dropna(subset=['customer_id', 'order_total']) df['order_total'] = df['order_total'].clip(lower=0) # Remove negative values df['order_month'] = df['order_date'].dt.to_period('M') ``` #### Step 2 — Revenue trend analysis ```python monthly = ( df.groupby('order_month') .agg(revenue=('order_total', 'sum'), orders=('order_id', 'nunique'), customers=('customer_id', 'nunique')) .reset_index() ) monthly['aov'] = monthly['revenue'] / monthly['orders'] # Average order value monthly['revenue_mom'] = monthly['revenue'].pct_change() # Month-over-month growth fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=True) axes[0].bar(monthly['order_month'].astype(str), monthly['revenue'], color='steelblue') axes[0].set_title('Monthly Revenue', fontsize=14, fontweight='bold') axes[0].set_ylabel('Revenue ($)') axes[1].plot(monthly['order_month'].astype(str), monthly['aov'], marker='o', color='coral') axes[1].set_title('Average Order Value', fontsize=14, fontweight='bold') axes[1].set_ylabel('AOV ($)') plt.xticks(rotation=45, ha='right') plt.tight_layout() plt.savefig('revenue_trend.png', dpi=150, bbox_inches='tight') plt.close() ``` #### Step 3 — Customer segmentation (RFM) ```python snapshot_date = df['order_date'].max() + pd.Timedelta(days=1) rfm = df.groupby('customer_id').agg( recency=('order_date', lambda x: (snapshot_date - x.max()).days), frequency=('order_id', 'nunique'), monetary=('order_total', 'sum') ) # Score each dimension 1-4 using quartiles for col in ['recency', 'frequency', 'monetary']: labels = [4, 3, 2, 1] if col == 'recency' else [1, 2, 3, 4] rfm[f'{col}_score'] = pd.qcut(rfm[col], q=4, labels=labels, duplicates='drop') rfm['rfm_score'] = (rfm['recency_score'].astype(int) + rfm['frequency_score'].astype(int) + rfm['monetary_score'].astype(int)) # Segment mapping def segment(row): r, f, m = int(row['recency_score']), int(row['frequency_score']), int(row['monetary_score']) if r >= 3 and f >= 3: return 'Champions' elif r >= 3 and f < 3: return 'New / Promising' elif r < 3 and f >= 3: return 'At Risk' else: return 'Needs Attention' rfm['segment'] = rfm.apply(segment, axis=1) print(rfm.groupby('segment').agg( count=('monetary', 'size'), avg_revenue=('monetary', 'mean'), avg_frequency=('frequency', 'mean') ).sort_values('avg_revenue', ascending=False)) ``` #### Step 4 — Cohort retention analysis ```python df['cohort'] = df.groupby('customer_id')['order_date'].transform('min').dt.to_period('M') df['order_period'] = df['order_date'].dt.to_period('M') df['cohort_index'] = (df['order_period'] - df['cohort']).apply(lambda x: x.n) cohort_table = ( df.groupby(['cohort', 'cohort_index'])['customer_id'] .nunique() .reset_index() .pivot(index='cohort', columns='cohort_index', values='customer_id') ) # Convert to retention percentages retention = cohort_table.div(cohort_table[0], axis=0) * 100 fig, ax = plt.subplots(figsize=(14, 8)) sns.heatmap(retention, annot=True, fmt='.0f', cmap='YlOrRd_r', ax=ax) ax.set_title('Cohort Retention (% of original customers)', fontsize=14, fontweight='bold') ax.set_xlabel('Months Since First Purchase') ax.set_ylabel('Cohort') plt.tight_layout() plt.savefig('cohort_retention.png', dpi=150, bbox_inches='tight') plt.close() ``` --- ### Example 2: A/B Test Analysis **Goal**: Evaluate whether a new checkout flow (variant B) improves conversion rate over the existing flow (variant A). #### Step 1 — Sample size calculation (pre-test) ```python from scipy import stats import numpy as np baseline_rate = 0.12 # Current conversion rate: 12% mde = 0.02 # Minimum detectable effect: 2 percentage points alpha = 0.05 # Significance level power = 0.80 # Statistical power # Using the normal approximation formula p1 = baseline_rate p2 = baseline_rate + mde p_avg = (p1 + p2) / 2 z_alpha = stats.norm.ppf(1 - alpha / 2) # Two-tailed z_beta = stats.norm.ppf(power) n_per_group = ((z_alpha * np.sqrt(2 * p_avg * (1 - p_avg)) + z_beta * np.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2 / (p2 - p1) ** 2) print(f"Required sample size per group: {int(np.ceil(n_per_group)):,}") print(f"Total required: {int(np.ceil(n_per_group)) * 2:,}") ``` #### Step 2 — Run the test and collect results ```python ab = pd.read_csv('ab_test_results.csv') summary = ab.groupby('variant').agg( visitors=('user_id', 'nunique'), conversions=('converted', 'sum') ) summary['conversion_rate'] = summary['conversions'] / summary['visitors'] print(summary) ``` #### Step 3 — Statistical significance ```python a = ab[ab['variant'] == 'A'] b = ab[ab['variant'] == 'B'] # Chi-squared test for proportions contingency = pd.crosstab(ab['variant'], ab['converted']) chi2, p_value, dof, expected = stats.chi2_contingency(contingency) # Proportions z-test (more direct) from statsmodels.stats.proportion import proportions_ztest successes = [summary.loc['B', 'conversions'], summary.loc['A', 'conversions']] trials = [summary.loc['B', 'visitors'], summary.loc['A', 'visitors']] z_stat, p_val = proportions_ztest(successes, trials, alternative='larger') print(f"Z-statistic: {z_stat:.4f}") print(f"P-value: {p_val:.4f}") print(f"Significant: {'Yes' if p_val < 0.05 else 'No'} (at alpha=0.05)") ``` #### Step 4 — Effect size and confidence interval ```python p_a = summary.loc['A', 'conversion_rate'] p_b = summary.loc['B', 'conversion_rate'] n_a = summary.loc['A', 'visitors'] n_b = summary.loc['B', 'visitors'] lift = (p_b - p_a) / p_a se_diff = np.sqrt(p_a * (1 - p_a) / n_a + p_b * (1 - p_b) / n_b) ci_lower = (p_b - p_a) - 1.96 * se_diff ci_upper = (p_b - p_a) + 1.96 * se_diff print(f"Control rate: {p_a:.4f}") print(f"Variant rate: {p_b:.4f}") print(f"Absolute lift: {p_b - p_a:+.4f}") print(f"Relative lift: {lift:+.2%}") print(f"95% CI for diff: [{ci_lower:+.4f}, {ci_upper:+.4f}]") ``` #### Step 5 — Recommendation template ``` ## A/B Test Report: New Checkout Flow | Metric | Control (A) | Variant (B) | |---------------------|-------------|-------------| | Visitors | 15,204 | 15,198 | | Conversions | 1,824 | 2,127 | | Conversion Rate | 12.00% | 13.99% | **Result**: Statistically significant (p = 0.0003, alpha = 0.05) **Lift**: +1.99pp absolute / +16.6% relative **95% CI**: [+0.90pp, +3.08pp] **Recommendation**: Deploy variant B. The effect is both statistically and practically significant with a lower bound above the +1pp threshold. ``` --- ### Example 3: Customer Churn Analysis **Goal**: Identify which factors most strongly predict customer churn and quantify their relative importance. #### Step 1 — Feature engineering ```python df = pd.read_csv('customers.csv') # Create behavioral features from raw data features = df.copy() features['tenure_months'] = (pd.Timestamp.now() - pd.to_datetime(df['signup_date'])).dt.days / 30 features['support_tickets_per_month'] = df['total_tickets'] / features['tenure_months'].clip(lower=1) features['avg_session_minutes'] = df['total_session_minutes'] / df['total_sessions'].clip(lower=1) features['days_since_last_login'] = (pd.Timestamp.now() - pd.to_datetime(df['last_login'])).dt.days features['has_premium'] = (df['plan'] == 'premium').astype(int) # Drop raw columns, keep engineered features feature_cols = [ 'tenure_months', 'support_tickets_per_month', 'avg_session_minutes', 'days_since_last_login', 'has_premium', 'monthly_spend', 'num_features_used' ] ``` #### Step 2 — Correlation analysis ```python churn_corr = features[feature_cols + ['churned']].corr()['churned'].drop('churned').sort_values() fig, ax = plt.subplots(figsize=(8, 5)) churn_corr.plot(kind='barh', ax=ax, color=['coral' if x > 0 else 'steelblue' for x in churn_corr]) ax.set_title('Feature Correlation with Churn', fontsize=14, fontweight='bold') ax.set_xlabel('Pearson Correlation') ax.axvline(x=0, color='black', linewidth=0.5) plt.tight_layout() plt.savefig('churn_correlations.png', dpi=150, bbox_inches='tight') plt.close() ``` #### Step 3 — Key driver identification via group comparison ```python churned = features[features['churned'] == 1] retained = features[features['churned'] == 0] comparison = [] for col in feature_cols: t_stat, p_val = stats.ttest_ind(churned[col].dropna(), retained[col].dropna()) d = cohens_d(churned[col].dropna(), retained[col].dropna()) # From earlier definition comparison.append({ 'feature': col, 'churned_mean': churned[col].mean(), 'retained_mean': retained[col].mean(), 'diff_pct': (churned[col].mean() - retained[col].mean()) / retained[col].mean() * 100, 'cohens_d': abs(d), 'p_value': p_val, 'significant': p_val < 0.05 }) result = pd.DataFrame(comparison).sort_values('cohens_d', ascending=False) print(result.to_string(index=False)) ``` #### Step 4 — Interpret and report ``` ## Churn Driver Analysis **Top 3 factors distinguishing churned vs. retained customers:** | Factor | Churned (avg) | Retained (avg) | Diff | Effect Size | |----------------------------|---------------|----------------|----------|-------------| | Days since last login | 34.2 | 8.7 | +293% | Large | | Support tickets per month | 2.8 | 0.9 | +211% | Large | | Number of features used | 3.1 | 7.4 | -58% | Medium | **Actionable insights:** 1. Customers inactive >14 days are 4x more likely to churn -- trigger re-engagement email at day 10 2. High support ticket rate signals frustration -- escalate accounts with >2 tickets/month to success team 3. Low feature adoption correlates with churn -- implement onboarding flow targeting unused features ``` --- ## Advanced pandas Patterns ### Window Functions ```python # Expanding window (cumulative statistics) df['cumulative_avg'] = df['value'].expanding().mean() df['cumulative_max'] = df['value'].expanding().max() # Exponentially weighted moving average (EWMA) -- emphasizes recent values df['ewma_7'] = df['value'].ewm(span=7).mean() # Span-based decay df['ewma_a'] = df['value'].ewm(alpha=0.3).mean() # Explicit decay factor # Comparison: rolling vs. EWMA # - rolling(7).mean() weights all 7 values equally # - ewm(span=7).mean() weights recent values exponentially more # Use EWMA when recent data matters more (stock prices, real-time metrics) # Rolling with min_periods (handles early rows with insufficient data) df['rolling_avg'] = df['value'].rolling(window=30, min_periods=5).mean() # Rolling rank (percentile within window) df['rolling_pctile'] = df['value'].rolling(90).rank(pct=True) ``` ### Multi-Index Operations ```python # Create multi-index from groupby multi = df.groupby(['region', 'product']).agg( revenue=('amount', 'sum'), units=('quantity', 'sum') ) # Access levels multi.loc['North'] # All products in North region multi.loc[('North', 'Widget')] # Specific region + product multi.xs('Widget', level='product') # All regions for Widget # Swap and sort levels multi = multi.swaplevel().sort_index() # Reset to flat columns flat = multi.reset_index() # Stack / unstack (reshape between long and wide) wide = multi['revenue'].unstack(level='product') # Products become columns long = wide.stack() # Back to multi-index ``` ### Merge and Join Patterns ```python # Inner join (only matching rows) merged = orders.merge(customers, on='customer_id', how='inner') # Left join with indicator (see which rows matched) merged = orders.merge(customers, on='customer_id', how='left', indicator=True) unmatched = merged[merged['_merge'] == 'left_only'] # Join on multiple keys merged = df1.merge(df2, on=['date', 'region'], how='left') # Join with different column names merged = orders.merge(products, left_on='prod_id', right_on='product_id') # Anti-join (rows in A that have no match in B) anti = df_a.merge(df_b, on='key', how='left', indicator=True) anti = anti[anti['_merge'] == 'left_only'].drop(columns='_merge') # Self-join (compare rows within same table) df_prev = df[['customer_id', 'order_date', 'amount']].rename( columns={'order_date': 'prev_date', 'amount': 'prev_amount'} ) df_with_prev = df.merge(df_prev, on='customer_id', how='left') df_with_prev = df_with_prev[df_with_prev['prev_date'] < df_with_prev['order_date']] ``` ### Apply and Transform ```python # transform() returns same-shaped output -- useful for group-level stats on each row df['group_mean'] = df.groupby('category')['value'].transform('mean') df['pct_of_group'] = df['value'] / df.groupby('category')['value'].transform('sum') df['z_within_group'] = df.groupby('category')['value'].transform( lambda x: (x - x.mean()) / x.std() ) # apply() for multi-column group operations def top_n(group, n=3): return group.nlargest(n, 'value') top3_per_category = df.groupby('category', group_keys=False).apply(top_n, n=3) # Vectorized operations (prefer these over apply when possible) # Slow: df['result'] = df.apply(lambda row: row['a'] * row['b'] + row['c'], axis=1) # Fast: df['result'] = df['a'] * df['b'] + df['c'] # np.where for conditional columns (vectorized if/else) df['tier'] = np.where(df['revenue'] > 10000, 'high', 'low') # np.select for multiple conditions conditions = [ df['revenue'] > 10000, df['revenue'] > 5000, df['revenue'] > 0, ] choices = ['high', 'medium', 'low'] df['tier'] = np.select(conditions, choices, default='none') ``` ### Memory Optimization for Large Datasets ```python # Check current memory usage print(df.memory_usage(deep=True).sum() / 1024**2, "MB") # Downcast numeric types df['int_col'] = pd.to_numeric(df['int_col'], downcast='integer') # int64 -> int8/16/32 df['float_col'] = pd.to_numeric(df['float_col'], downcast='float') # float64 -> float32 # Use category type for low-cardinality strings for col in df.select_dtypes(include='object'): if df[col].nunique() / len(df) < 0.5: # Less than 50% unique values df[col] = df[col].astype('category') # Read in chunks for files that exceed memory chunks = pd.read_csv('huge_file.csv', chunksize=100_000) results = [] for chunk in chunks: processed = chunk.groupby('category')['value'].sum() results.append(processed) final = pd.concat(results).groupby(level=0).sum() # Specify dtypes at load time (avoids loading as float64/object first) dtypes = { 'id': 'int32', 'category': 'category', 'value': 'float32', 'flag': 'bool' } df = pd.read_csv('data.csv', dtype=dtypes) # Use pyarrow backend for better memory efficiency (pandas 2.0+) df = pd.read_csv('data.csv', engine='pyarrow', dtype_backend='pyarrow') ``` --- ## Dashboard and Reporting Patterns ### Executive Dashboard Template ```python import matplotlib.pyplot as plt import matplotlib.gridspec as gridspec from matplotlib.patches import FancyBboxPatch def executive_dashboard(kpis, trend_df, comparison_df, output='dashboard.png'): """ kpis: dict with keys like {'Revenue': '$1.2M', 'Growth': '+15%', ...} trend_df: DataFrame with 'date' and 'value' columns comparison_df: DataFrame with 'category' and 'current'/'previous' columns """ fig = plt.figure(figsize=(16, 10)) gs = gridspec.GridSpec(3, len(kpis), hspace=0.4, wspace=0.3) # Row 1: KPI cards for i, (label, value) in enumerate(kpis.items()): ax = fig.add_subplot(gs[0, i]) ax.text(0.5, 0.6, value, ha='center', va='center', fontsize=28, fontweight='bold', color='#2c3e50') ax.text(0.5, 0.2, label, ha='center', va='center', fontsize=12, color='#7f8c8d') ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis('off') # Card background rect = FancyBboxPatch((0.05, 0.05), 0.9, 0.9, boxstyle="round,pad=0.05", facecolor='#f8f9fa', edgecolor='#dee2e6') ax.add_patch(rect) # Row 2: Trend line ax_trend = fig.add_subplot(gs[1, :]) ax_trend.plot(trend_df['date'], trend_df['value'], linewidth=2, color='steelblue') ax_trend.fill_between(trend_df['date'], trend_df['value'], alpha=0.1, color='steelblue') ax_trend.set_title('Trend Over Time', fontsize=13, fontweight='bold') ax_trend.set_ylabel('Value') # Row 3: Period comparison (grouped bar) ax_comp = fig.add_subplot(gs[2, :]) x = range(len(comparison_df)) width = 0.35 ax_comp.bar([i - width/2 for i in x], comparison_df['previous'], width, label='Previous', color='#bdc3c7') ax_comp.bar([i + width/2 for i in x], comparison_df['current'], width, label='Current', color='steelblue') ax_comp.set_xticks(list(x)) ax_comp.set_xticklabels(comparison_df['category'], rotation=45, ha='right') ax_comp.set_title('Current vs. Previous Period', fontsize=13, fontweight='bold') ax_comp.legend() plt.savefig(output, dpi=150, bbox_inches='tight', facecolor='white') plt.close() ``` ### Weekly Metrics Report Template ```python def weekly_report(df, date_col='date', metric_col='value', group_col=None): """Generate a standard weekly metrics summary.""" df[date_col] = pd.to_datetime(df[date_col]) df['week'] = df[date_col].dt.isocalendar().week.astype(int) df['year'] = df[date_col].dt.year current_week = df['week'].max() prev_week = current_week - 1 curr = df[df['week'] == current_week] prev = df[df['week'] == prev_week] report = { 'period': f"Week {current_week}", 'total': curr[metric_col].sum(), 'mean': curr[metric_col].mean(), 'median': curr[metric_col].median(), 'wow_change': (curr[metric_col].sum() - prev[metric_col].sum()) / prev[metric_col].sum() * 100 if prev[metric_col].sum() != 0 else None, } if group_col: report['by_group'] = curr.groupby(group_col)[metric_col].agg(['sum', 'mean', 'count']) # Sparkline trend (last 8 weeks) weekly_totals = ( df.groupby('week')[metric_col].sum() .tail(8) .reset_index() ) fig, ax = plt.subplots(figsize=(6, 2)) ax.plot(weekly_totals['week'], weekly_totals[metric_col], marker='o', linewidth=2, color='steelblue', markersize=4) ax.fill_between(weekly_totals['week'], weekly_totals[metric_col], alpha=0.1, color='steelblue') ax.set_title(f'{metric_col.title()} — Last 8 Weeks', fontsize=10) ax.tick_params(labelsize=8) plt.tight_layout() plt.savefig('weekly_sparkline.png', dpi=150, bbox_inches='tight') plt.close() return report ``` ### Anomaly Detection Patterns ```python def detect_anomalies(series, method='zscore', threshold=3.0, window=30): """ Detect anomalies in a numeric series. Methods: - 'zscore': Flag values beyond `threshold` standard deviations from mean - 'iqr': Flag values beyond 1.5x IQR from quartiles - 'rolling': Flag values beyond `threshold` std devs from rolling mean """ anomalies = pd.Series(False, index=series.index) if method == 'zscore': z = (series - series.mean()) / series.std() anomalies = z.abs() > threshold elif method == 'iqr': q1 = series.quantile(0.25) q3 = series.quantile(0.75) iqr = q3 - q1 anomalies = (series < q1 - 1.5 * iqr) | (series > q3 + 1.5 * iqr) elif method == 'rolling': rolling_mean = series.rolling(window, min_periods=5).mean() rolling_std = series.rolling(window, min_periods=5).std() anomalies = (series - rolling_mean).abs() > threshold * rolling_std return anomalies # Usage: detect and visualize anomalies = detect_anomalies(df['metric'], method='rolling', threshold=2.5, window=30) fig, ax = plt.subplots(figsize=(14, 5)) ax.plot(df.index, df['metric'], linewidth=1, color='steelblue', label='Metric') ax.scatter(df.index[anomalies], df['metric'][anomalies], color='red', s=40, zorder=5, label='Anomaly') ax.legend() ax.set_title('Anomaly Detection (Rolling Z-Score)', fontsize=14, fontweight='bold') plt.tight_layout() plt.savefig('anomalies.png', dpi=150, bbox_inches='tight') plt.close() print(f"Detected {anomalies.sum()} anomalies out of {len(series):,} data points") ``` **Method selection guide:** | Method | Best For | Assumptions | Sensitivity | |--------|----------|-------------|-------------| | Z-score | Stationary data with normal distribution | Constant mean and variance | Low (misses local anomalies) | | IQR | Skewed distributions, outlier screening | None (non-parametric) | Medium | | Rolling z-score | Time series with trends or seasonality | Local stationarity within window | High (adapts to drift) | --- ## Common Analytics Pitfalls ### Simpson's Paradox A trend that appears in grouped data reverses when the groups are combined. ``` Department A: Drug works better (80% vs 70%) Department B: Drug works better (50% vs 40%) Combined: Drug appears WORSE (55% vs 60%) <-- paradox ``` **Why it happens**: Unequal group sizes create a confounding effect. Department B (with lower overall rates) sent most patients to the drug group. **Prevention**: Always segment data by relevant confounders before drawing conclusions. If aggregate and segmented results disagree, trust the segmented analysis and report the confounding variable. ### Survivorship Bias Analyzing only entities that "survived" a selection process, ignoring those that dropped out. **Classic examples:** - Studying only successful companies to find success patterns (ignoring failed companies with the same patterns) - Analyzing only current customers to understand satisfaction (ignoring those who already left) - Looking at fund performance by examining only funds that still exist (dead funds were closed) **Prevention**: Always ask "what is missing from this dataset?" before drawing conclusions. If possible, include data from non-survivors. Explicitly note the selection criteria and what it excludes. ### Correlation vs. Causation A statistically significant correlation between X and Y does not mean X causes Y. Possible explanations: | Explanation | Example | |-------------|---------| | X causes Y | Exercise reduces blood pressure | | Y causes X | Depression reduces exercise (not exercise causes depression) | | Z causes both | Income drives both education spending AND health outcomes | | Coincidence | Ice cream sales correlate with drowning deaths (both driven by summer) | **Prevention**: Establish causation only with randomized controlled experiments (A/B tests). For observational data, state findings as "associated with" not "causes." Look for confounders and test whether the relationship holds when controlling for them. ### Cherry-Picking Time Windows Selecting a start/end date that makes a metric look better or worse than the true trend. ```python # Example: same data, different conclusions # "Revenue up 40%!" -- comparing Jan (seasonal low) to Dec (seasonal high) # "Revenue flat." -- comparing Dec 2024 to Dec 2025 (year-over-year) # Prevention: always use year-over-year comparison for seasonal data df['yoy_change'] = df.groupby(df['date'].dt.month)['revenue'].pct_change(periods=12) ``` **Prevention checklist:** - Compare like-for-like periods (YoY for seasonal businesses) - Show the full time range, not a selected subset - Use multiple time windows (WoW, MoM, QoQ, YoY) and note if they disagree - Include a moving average to show the underlying trend separate from noise ### Small Sample Size Issues Small samples produce unstable statistics that can flip with just a few more observations. ```python # Illustrate instability: conversion rates with small vs. large samples from scipy.stats import beta # Scenario: 3 conversions out of 10 visitors (30%) a_small, b_small = 3 + 1, 10 - 3 + 1 # Beta posterior ci_small = beta.interval(0.95, a_small, b_small) print(f"n=10: 30% conversion, 95% CI: [{ci_small[0]:.1%}, {ci_small[1]:.1%}]") # Output: 95% CI: [9.9%, 56.8%] -- extremely wide, almost useless # Scenario: 300 conversions out of 1000 visitors (30%) a_large, b_large = 300 + 1, 1000 - 300 + 1 ci_large = beta.interval(0.95, a_large, b_large) print(f"n=1000: 30% conversion, 95% CI: [{ci_large[0]:.1%}, {ci_large[1]:.1%}]") # Output: 95% CI: [27.2%, 32.9%] -- narrow and actionable ``` **Rules of thumb:** - n < 30: Do not draw firm conclusions. Report as directional only. - Conversion rates need hundreds (not dozens) of conversions to stabilize. - Always report confidence intervals alongside point estimates. - If sample size is fixed and small, use exact tests (Fisher's exact) rather than approximations (chi-squared).