EVIKA ACADEMY · DATA ANALYTICS SERIES · CHAPTER 83

Advanced Python Data Analysis

Method chaining, pipe(), custom aggregations, memory optimisation, multi-index, cross-tabs, Plotly interactive charts, and scipy statistical testing.

⏱ 25 min read🐍 Python · Pandas · Plotly · Scipy
← Ch 82: Google Looker StudioCh 84: SQL Stored Procedures & Views →

Chapter 75 covered Pandas fundamentals — loading data, selecting rows and columns, cleaning nulls, groupby, merge, and reshape. This chapter goes deeper: the patterns that separate analysts who use Pandas competently from those who use it fluently.

Fluent Pandas code is concise, readable, and fast. It avoids unnecessary loops, stores only what it needs in memory, and chains operations into coherent pipelines that read like a description of the analysis rather than a set of mechanical steps. These patterns also show up directly in data science interviews — the ability to manipulate a DataFrame efficiently under time pressure is a reliable signal of genuine Python experience.

Method Chaining: Writing Pandas Like a Pipeline

Method chaining calls multiple Pandas operations in sequence on the same object, each returning a DataFrame that the next method acts on. The result is a single expression that describes the full transformation clearly — with no intermediate variables cluttering the namespace.

Basic chaining pattern

import pandas as pd

# Unchained (verbose, creates three throwaway variables)
df1 = df.dropna(subset=['revenue'])
df2 = df1[df1['revenue'] > 0]
df3 = df2.assign(margin=lambda x: (x['revenue'] - x['cost']) / x['revenue'])
result = df3.sort_values('margin', ascending=False)

# Chained (one expression, same result)
result = (
    df
    .dropna(subset=['revenue'])
    .query('revenue > 0')
    .assign(margin=lambda x: (x['revenue'] - x['cost']) / x['revenue'])
    .sort_values('margin', ascending=False)
)

assign() — the workhorse of chaining

# assign() adds or overwrites columns and returns a new DataFrame
# Lambda receives the DataFrame at that point in the chain

result = (
    df
    .assign(
        revenue_k    = lambda x: x['revenue'] / 1000,
        margin_pct   = lambda x: (x['revenue'] - x['cost']) / x['revenue'] * 100,
        is_profitable= lambda x: x['margin_pct'] > 0,
        month        = lambda x: x['order_date'].dt.to_period('M'),
    )
)

# Chain multiple assign() calls when later columns depend on earlier ones
result = (
    df
    .assign(revenue_k = lambda x: x['revenue'] / 1000)
    .assign(revenue_tier = lambda x: pd.cut(
        x['revenue_k'],
        bins=[0, 10, 50, 200, float('inf')],
        labels=['Small', 'Medium', 'Large', 'Enterprise']
    ))
)

pipe() — injecting custom functions into a chain

# pipe() calls a function with the DataFrame as the first argument
# This lets you include any custom function in a chain

def remove_test_accounts(df, email_col='email'):
    """Drop rows where email contains 'test' or '@example'."""
    mask = (
        df[email_col].str.contains('test', case=False, na=False) |
        df[email_col].str.contains('@example', case=False, na=False)
    )
    return df[~mask]

def add_customer_tier(df, revenue_col='ltv'):
    bins   = [0, 1000, 5000, 20000, float('inf')]
    labels = ['Bronze', 'Silver', 'Gold', 'Platinum']
    return df.assign(tier=pd.cut(df[revenue_col], bins=bins, labels=labels))

def log_shape(df, label=''):
    """Debug step — prints shape without breaking the chain."""
    print(f"{label}: {df.shape}")
    return df

result = (
    raw_df
    .pipe(remove_test_accounts, email_col='user_email')
    .pipe(log_shape, label='after removing test accounts')
    .dropna(subset=['ltv'])
    .pipe(add_customer_tier, revenue_col='ltv')
    .query('tier != "Bronze"')
    .reset_index(drop=True)
)

Advanced groupby Patterns

Named aggregations with groupby().agg()

# Named aggregations: control output column names exactly
summary = (
    df
    .groupby('region')
    .agg(
        total_revenue  = ('revenue', 'sum'),
        avg_order_value= ('revenue', 'mean'),
        order_count    = ('order_id', 'count'),
        unique_customers=('customer_id', 'nunique'),
        max_single_order=('revenue', 'max'),
        first_order_date=('order_date', 'min'),
        last_order_date =('order_date', 'max'),
    )
    .round(2)
    .reset_index()
)

Custom aggregation functions

# Pass any callable — receives the group Series
def pct_above_threshold(s, threshold=1000):
    return (s > threshold).mean() * 100

def iqr(s):
    return s.quantile(0.75) - s.quantile(0.25)

summary = (
    df
    .groupby('category')
    .agg(
        revenue_iqr      = ('revenue', iqr),
        pct_large_orders = ('revenue', lambda s: pct_above_threshold(s, 1000)),
    )
)

transform() — broadcast group result back to original rows

# transform() returns a Series with the same index as the input
# Use it to add group-level stats as new columns without collapsing rows

df = df.assign(
    region_total   = df.groupby('region')['revenue'].transform('sum'),
    region_avg     = df.groupby('region')['revenue'].transform('mean'),
    # % contribution of this row to its region total
    pct_of_region  = lambda x: x['revenue'] / x['region_total'] * 100,
    # Rank within region (highest revenue = rank 1)
    rank_in_region = df.groupby('region')['revenue'].transform(
        lambda s: s.rank(ascending=False, method='dense')
    ),
)

filter() — keep only groups matching a condition

# Keep only groups (regions) with more than 100 orders
large_regions = df.groupby('region').filter(lambda g: len(g) > 100)

# Keep only groups where the average revenue exceeds 5000
high_value_regions = df.groupby('region').filter(
    lambda g: g['revenue'].mean() > 5000
)

Multi-Index and Cross-Tabulation

Multi-index groupby

# Group by multiple dimensions — creates a MultiIndex result
pivot = (
    df
    .groupby(['region', 'category'])['revenue']
    .agg(['sum', 'count', 'mean'])
    .round(0)
)

# Access a specific region
pivot.loc['North']

# Flatten MultiIndex columns after agg
pivot.columns = ['_'.join(col).strip() for col in pivot.columns]

# Reset to flat DataFrame
pivot = pivot.reset_index()

pd.crosstab() — frequency tables and proportions

import pandas as pd

# Basic count crosstab
ct = pd.crosstab(df['region'], df['category'])

# With margins (row/column totals)
ct_with_totals = pd.crosstab(
    df['region'], df['category'],
    margins=True, margins_name='Total'
)

# Normalise to proportions
ct_pct = pd.crosstab(
    df['region'], df['category'],
    normalize='index'   # 'index'=row%, 'columns'=col%, 'all'=grand total%
).round(3)

# With an aggregation value (not just count)
ct_revenue = pd.crosstab(
    df['region'], df['category'],
    values=df['revenue'], aggfunc='sum'
)

Memory Optimisation

A 1 million-row DataFrame with default dtypes can consume 500 MB+ of RAM. On a laptop or a cloud notebook with limited memory, this causes slowdowns or crashes. These techniques typically reduce memory usage by 50–80% with no loss of data.

import pandas as pd

def optimise_dataframe(df):
    """Downcast numeric types and categorise low-cardinality strings."""
    df = df.copy()

    for col in df.select_dtypes(include=['int64']).columns:
        df[col] = pd.to_numeric(df[col], downcast='integer')

    for col in df.select_dtypes(include=['float64']).columns:
        df[col] = pd.to_numeric(df[col], downcast='float')

    for col in df.select_dtypes(include=['object']).columns:
        if df[col].nunique() / len(df) < 0.05:  # < 5% unique = low cardinality
            df[col] = df[col].astype('category')

    return df

# Check memory before and after
print(f"Before: {df.memory_usage(deep=True).sum() / 1e6:.1f} MB")
df = optimise_dataframe(df)
print(f"After:  {df.memory_usage(deep=True).sum() / 1e6:.1f} MB")

# Read only what you need from the start
df = pd.read_csv(
    'large_file.csv',
    usecols=['order_id', 'revenue', 'region', 'order_date'],
    dtype={'region': 'category', 'order_id': 'int32'},
    parse_dates=['order_date'],
)

Vectorised Operations vs apply()

import pandas as pd
import numpy as np

# SLOW: apply() with a Python function (row-by-row)
df['tier'] = df['revenue'].apply(
    lambda x: 'High' if x > 10000 else ('Mid' if x > 1000 else 'Low')
)

# FAST: np.select() — vectorised conditional
conditions  = [df['revenue'] > 10000, df['revenue'] > 1000]
choices     = ['High', 'Mid']
df['tier']  = np.select(conditions, choices, default='Low')

# FAST: pd.cut() for binning
df['tier'] = pd.cut(
    df['revenue'],
    bins=[0, 1000, 10000, float('inf')],
    labels=['Low', 'Mid', 'High']
)

# FAST: str accessor for string operations (vectorised)
df['region_clean'] = df['region'].str.upper().str.strip()
df['has_discount'] = df['promo_code'].str.startswith('DISC')

# Rule of thumb: if you can express it with Series/array operations
# or numpy — do that. Reserve apply() for logic that genuinely
# cannot be vectorised (API calls, custom objects, complex branching).

Plotly: Interactive Charts for Analysis

Matplotlib is excellent for static charts in reports. Plotly produces interactive charts — hover tooltips, zoom, pan, toggle traces — that are far more useful for exploratory analysis and stakeholder demos.

Line chart with multiple traces

import plotly.express as px
import pandas as pd

monthly = (
    df
    .assign(month=lambda x: x['order_date'].dt.to_period('M').dt.to_timestamp())
    .groupby(['month', 'region'])['revenue']
    .sum()
    .reset_index()
)

fig = px.line(
    monthly,
    x='month', y='revenue', color='region',
    title='Monthly Revenue by Region',
    labels={'revenue': 'Revenue (₹)', 'month': ''},
    template='plotly_white',
)
fig.update_layout(legend_title_text='Region', hovermode='x unified')
fig.show()

Scatter with size and colour encoding

fig = px.scatter(
    df,
    x='spend', y='revenue',
    size='order_count',       # bubble size
    color='category',         # colour by dimension
    hover_data=['region', 'margin_pct'],
    title='Spend vs Revenue by Category',
    trendline='ols',          # add regression line per group
    template='plotly_white',
    labels={'spend': 'Marketing Spend (₹)', 'revenue': 'Revenue (₹)'},
)
fig.show()

Subplots: multiple charts in one figure

from plotly.subplots import make_subplots
import plotly.graph_objects as go

fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=['Revenue by Region', 'Order Count Trend',
                    'Margin Distribution', 'Top 10 Products'],
)

# Row 1 Col 1 — bar chart
fig.add_trace(
    go.Bar(x=region_df['region'], y=region_df['revenue'], name='Revenue'),
    row=1, col=1
)

# Row 1 Col 2 — line chart
fig.add_trace(
    go.Scatter(x=trend_df['date'], y=trend_df['orders'], name='Orders'),
    row=1, col=2
)

# Row 2 Col 1 — histogram
fig.add_trace(
    go.Histogram(x=df['margin_pct'], name='Margin %', nbinsx=30),
    row=2, col=1
)

fig.update_layout(height=700, title_text='Executive Dashboard', showlegend=False)
fig.show()

Scipy Statistical Testing

t-test: are two group means significantly different?

from scipy import stats
import pandas as pd

group_a = df[df['variant'] == 'A']['conversion_rate']
group_b = df[df['variant'] == 'B']['conversion_rate']

# Independent two-sample t-test
t_stat, p_value = stats.ttest_ind(group_a, group_b, equal_var=False)

print(f"t-statistic: {t_stat:.4f}")
print(f"p-value:     {p_value:.4f}")
print(f"Result: {'Significant difference' if p_value < 0.05 else 'No significant difference'} at α=0.05")

# Effect size (Cohen's d)
pooled_std = ((group_a.std()**2 + group_b.std()**2) / 2) ** 0.5
cohens_d   = (group_a.mean() - group_b.mean()) / pooled_std
print(f"Cohen's d: {cohens_d:.3f}  ({'large' if abs(cohens_d)>0.8 else 'medium' if abs(cohens_d)>0.5 else 'small'} effect)")

Chi-square test: is there an association between two categorical variables?

from scipy.stats import chi2_contingency
import pandas as pd

# Contingency table: payment method vs order outcome
ct = pd.crosstab(df['payment_method'], df['order_status'])

chi2, p, dof, expected = chi2_contingency(ct)
print(f"Chi-square: {chi2:.4f}")
print(f"p-value:    {p:.4f}")
print(f"Degrees of freedom: {dof}")
print(f"Association: {'Significant' if p < 0.05 else 'Not significant'} at α=0.05")

Correlation: Pearson and Spearman

import pandas as pd
from scipy.stats import pearsonr, spearmanr

# Pearson (linear relationships, assumes normal distribution)
r, p = pearsonr(df['ad_spend'], df['revenue'])
print(f"Pearson r={r:.3f}, p={p:.4f}")

# Spearman (monotonic relationships, no normality assumption — more robust)
rho, p = spearmanr(df['ad_spend'], df['revenue'])
print(f"Spearman ρ={rho:.3f}, p={p:.4f}")

# Full correlation matrix (Pandas built-in)
corr_matrix = df[['revenue', 'ad_spend', 'sessions', 'conversion_rate']].corr()

# Heatmap with Plotly
import plotly.express as px
fig = px.imshow(
    corr_matrix, text_auto='.2f', aspect='auto',
    color_continuous_scale='RdBu_r', zmin=-1, zmax=1,
    title='Correlation Matrix'
)
fig.show()

Performance Profiling: Finding Slow Code

import pandas as pd
import time

# Time a single operation
start = time.perf_counter()
result = df.groupby('region')['revenue'].sum()
elapsed = time.perf_counter() - start
print(f"groupby sum: {elapsed*1000:.2f}ms")

# In Jupyter — use %%timeit magic
# %%timeit
# df.groupby('region')['revenue'].sum()

# Profile memory of a specific expression
import tracemalloc
tracemalloc.start()
result = df.merge(lookup, on='product_id', how='left')
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"Peak memory: {peak / 1e6:.1f} MB")

# Common bottlenecks and fixes:
# SLOW: df.apply(fn, axis=1)      → use vectorised ops or np.select
# SLOW: iterrows() / itertuples() → use vectorised ops
# SLOW: repeated concatenation    → collect list of dfs, then pd.concat()
# SLOW: str ops on object dtype   → use .str accessor (vectorised)
# SLOW: full scan on large df     → filter early, set_index for repeated key lookups

6 Advanced Pandas Patterns Worth Memorising

Flatten a list column into rows
df.explode('tags')  # each tag gets its own row
First/last N rows per group
df.groupby('customer_id').head(3)   # first 3 orders per customer
df.groupby('customer_id').tail(1)   # most recent order per customer
Shift within group (lag feature)
df['prev_revenue'] = df.groupby('customer_id')['revenue'].shift(1)
Rolling within group
df['rolling_avg_3'] = (
    df.groupby('region')['revenue']
    .transform(lambda s: s.rolling(3, min_periods=1).mean())
)
Wide-to-long (unpivot / melt)
pd.melt(
    df, id_vars=['product'], value_vars=['Q1','Q2','Q3','Q4'],
    var_name='quarter', value_name='revenue'
)
Combine fillna strategies
df['revenue'] = (
    df['revenue']
    .fillna(df.groupby('region')['revenue'].transform('median'))
)
← Ch 82: Google Looker StudioCh 84: SQL Stored Procedures & Views →
Master Python for Data Analytics in Noida
EVIKA ACADEMY · Sector 51, Noida · Live classes online & offline
Book Free DemoWhatsApp Us
🎓 Free Demo Class — Online & Offline · Noida Sector 51