This skill ensures you understand your data thoroughly before making statistical claims.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import os
# MANDATORY: Dataset overview
print("=" * 60)
print("DATASET OVERVIEW")
print("=" * 60)
print(f"Observations: {len(df):,}")
print(f"Variables: {len(df.columns)}")
print(f"\nVariable Types:")
print(df.dtypes.value_counts())
# MANDATORY: Identify numeric and categorical columns
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
categorical_cols = df.select_dtypes(include=['object', 'category']).columns.tolist()
# MANDATORY: Summary statistics for numeric variables
if numeric_cols:
print("\n" + "=" * 60)
print("NUMERIC VARIABLES SUMMARY")
print("=" * 60)
summary = df[numeric_cols].describe(percentiles=[.01, .05, .25, .5, .75, .95, .99])
print(summary.round(3))
# MANDATORY: Check for outliers using IQR method
print("\n" + "=" * 60)
print("OUTLIER DETECTION (IQR Method)")
print("=" * 60)
for col in numeric_cols[:10]: # Limit to first 10 for brevity
Q1 = df[col].quantile(0.25)
Q3 = df[col].quantile(0.75)
IQR = Q3 - Q1
outliers = df[(df[col] < Q1 - 1.5*IQR) | (df[col] > Q3 + 1.5*IQR)][col]
if len(outliers) > 0:
print(f"{col}: {len(outliers)} outliers ({len(outliers)/len(df)*100:.1f}%)")
# MANDATORY: Missing data analysis
print("\n" + "=" * 60)
print("MISSING DATA ANALYSIS")
print("=" * 60)
missing = df.isnull().sum()
missing_pct = (missing / len(df)) * 100
missing_df = pd.DataFrame({
'Missing_Count': missing,
'Missing_Percentage': missing_pct
})
missing_df = missing_df[missing_df['Missing_Count'] > 0].sort_values('Missing_Count', ascending=False)
if len(missing_df) > 0:
print(missing_df.round(2))
print(f"\nTotal rows with any missing: {df.isnull().any(axis=1).sum()} ({df.isnull().any(axis=1).sum()/len(df)*100:.1f}%)")
else:
print("No missing values detected")
# MANDATORY: Categorical variables summary
if categorical_cols:
print("\n" + "=" * 60)
print("CATEGORICAL VARIABLES SUMMARY")
print("=" * 60)
for col in categorical_cols[:10]: # Limit to first 10
n_unique = df[col].nunique()
print(f"\n{col}: {n_unique} unique values")
if n_unique <= 20:
print(df[col].value_counts().head(10))
# MANDATORY: Distribution plots for numeric variables
if numeric_cols:
n_cols = min(len(numeric_cols), 9) # Max 9 subplots
n_rows = (n_cols + 2) // 3
n_cols_plot = min(3, n_cols)
fig, axes = plt.subplots(n_rows, n_cols_plot, figsize=(15, n_rows*4))
axes = axes.flatten() if n_rows > 1 else [axes] if n_cols == 1 else axes
for i, col in enumerate(numeric_cols[:9]):
data = df[col].dropna()
# Histogram with KDE
axes[i].hist(data, bins=30, density=True, alpha=0.7, edgecolor='black')
axes[i].set_title(f'{col}\nSkew: {data.skew():.2f}, Kurt: {data.kurtosis():.2f}')
axes[i].set_xlabel(col)
axes[i].set_ylabel('Density')
# Add KDE if enough data
if len(data) > 10:
from scipy import stats
kde = stats.gaussian_kde(data)
x_range = np.linspace(data.min(), data.max(), 100)
axes[i].plot(x_range, kde(x_range), 'r-', linewidth=2)
# Hide unused subplots
for i in range(len(numeric_cols[:9]), len(axes)):
axes[i].set_visible(False)
plt.tight_layout()
os.makedirs("./images", exist_ok=True)
plt.savefig("./images/distributions_overview.png", dpi=144, bbox_inches="tight")
print(f"\nSUCCESS: Distribution plots saved to ./images/distributions_overview.png")
# Save figure for inline display
fig = plt.gcf()
return summary, fig,
else:
return None, None,
</code_template>
</implementation_pattern>
<examples>
<example context="clean_data" difficulty="basic">
<description>Basic descriptive statistics for clean survey data</description>
<code>
```python
@app.cell
def describe_survey_data(df):
#Survey data requires checking for response patterns,
# missing data from non-response, and scale distributions to
# identify potential response biases or data entry errors.
import pandas as pd
import numpy as np
# Check response scales
likert_cols = [col for col in df.columns if 'rating' in col.lower() or 'scale' in col.lower()]
if likert_cols:
print("Likert Scale Variables:")
for col in likert_cols:
print(f"\n{col}:")
print(f" Range: {df[col].min()} - {df[col].max()}")
print(f" Mean: {df[col].mean():.2f}, Median: {df[col].median():.2f}")
print(f" % at ceiling: {(df[col] == df[col].max()).mean()*100:.1f}%")
print(f" % at floor: {(df[col] == df[col].min()).mean()*100:.1f}%")
# Check for response patterns (straight-lining)
if len(likert_cols) > 3:
straight_line = (df[likert_cols].std(axis=1) == 0).sum()
print(f"\nStraight-lining detected: {straight_line} respondents ({straight_line/len(df)*100:.1f}%)")
return None,
Format 1 (Binary/Dummy): ODK, SurveyCTO, Qualtrics, RedCap
- One column per option with values {0, 1, NaN}
- Example: 'activities/Business', 'activities/Farming'
Format 2 (Concatenated): Google Forms, Excel, TypeForm
- One column with space/comma-separated text
- Example: 'activities' = "Business Farming CHE"
"""
import pandas as pd
# STEP 1: Identify potential multi-select columns
# Look for column name patterns
potential_multiselect = [col for col in df.columns
if 'select all' in col.lower()
or col.count('/') > 0 # Binary format indicator
or 'activities' in col.lower()]
print("=" * 70)
print("MULTI-SELECT QUESTION FORMAT INSPECTION")
print("=" * 70)
# STEP 2: For each potential column, check format
for col in potential_multiselect[:5]: # Check first 5
print(f"\nColumn: {col}")
print(f"Data type: {df[col].dtype}")
# Check unique values
unique_vals = df[col].dropna().unique()
n_unique = len(unique_vals)
print(f"Unique values: {n_unique}")
# DIAGNOSTIC: Is this binary format?
if df[col].dtype in ['float64', 'int64'] and set(unique_vals).issubset({0, 1}):
print("→ FORMAT: Binary/Dummy variable (count where value = 1)")
missing = df[col].isna().sum()
zeros = (df[col] == 0).sum()
== 1).sum()
print(f" Missing: {missing} ({missing/len(df)*100:.1f}%)")
print(f" 0 (not selected): {zeros}")
print(f" 1 (selected): {ones}")
# DIAGNOSTIC: Is this concatenated format?
elif df[col].dtype == 'object':
print("→ FORMAT: Concatenated string (parse with .str.contains)")
missing = df[col].isna().sum()
print(f" Missing: {missing} ({missing/len(df)*100:.1f}%)")
print(f" Sample values:")
for val in df[col].dropna().head(3):
val_str = str(val)[:70] + "..." if len(str(val)) > 70 else str(val)
print(f" - {val_str}")
else:
print(f"→ UNKNOWN FORMAT - Manual inspection needed")
return None,
```python
@app.cell
def analyze_multiselect_binary(df):
"""Count responses for binary/dummy format multi-select questions.
Common in ODK/SurveyCTO exports.
"""
import pandas as pd
# Identify all binary columns for a multi-select question
question_prefix = "What are your main income-generating activities? (select all that apply)/"
activity_cols = [col for col in df.columns if col.startswith(question_prefix)]
print("=" * 70)
print("BINARY FORMAT MULTI-SELECT ANALYSIS")
print("=" * 70)
print(f"Total respondents: {len(df)}")
print()
results = []
for col in activity_cols:
activity_name = col.replace(question_prefix, "")
# CRITICAL: Count only where value = 1 (NOT .notna() which counts 0s too!)
count = (df[col] == 1).sum()
percentage = (count / len(df)) * 100
results.append({
'Activity': activity_name,
'N': count,
'Percentage': percentage
})
results_df = pd.DataFrame(results).sort_values('N', ascending=False)
print(results_df.to_string(index=False))
# Calculate average selections per person
total_selections = sum(r['N'] for r in results)
avg_per_person = total_selections / len(df)
print(f"\nAverage selections per person: {avg_per_person:.2f}")
return results_df,
@app.cell
def analyze_multiselect_concatenated(df):
"""Parse concatenated string format multi-select questions.
Common in Google Forms/Excel exports.
"""
import pandas as pd
col = 'main_income_generating' # Adjust column name
assert col in df.columns, f"Column {col} not found"
# Define possible options to search for
possible_activities = [
'Community Health Entrepreneur (CHE)',
'Community Health Promoter (CHP)',
'Business',
'Farming',
'Casual work',
'Teaching',
'Tailoring',
'Other'
]
print("=" * 70)
print("CONCATENATED STRING MULTI-SELECT ANALYSIS")
print("=" * 70)
print(f"Total respondents: {len(df)}")
print(f"Missing: {df[col].isna().sum()}")
print()
results = []
for activity in possible_activities:
# CRITICAL: Use regex=False for literal matching (parentheses are special in regex!)
count = df[col].str.contains(activity, na=False, regex=False).sum()
percentage = (count / len(df)) * 100
if count > 0: # Only include if found
results.append({
'Activity': activity,
'N': count,
'Percentage': percentage
})
results_df = pd.DataFrame(results).sort_values('N', ascending=False)
print(results_df.to_string(index=False))
# Calculate average selections per person
total_selections = sum(r['N'] for r in results)
avg_per_person = total_selections / len(df)
print(f"\nAverage selections per person: {avg_per_person:.2f}")
return results_df,
Concatenated format:
- Sample values show multiple activities in one string
- MUST use
regex=False in .str.contains() if options have special chars like parentheses
- Delimiter varies (space, comma, semicolon) - inspect samples first
- Person can select multiple activities → counts will sum to > 100%
Common error: Using .notna() on binary format → counts all non-missing as "selected"
Fix: Always use == 1 for binary, .str.contains() for strings
ALWAYS run format inspection first:
- Check data type (float/int = binary, object = string)
- Check unique values (
value_counts(dropna=False))
- View sample rows to confirm structure
- Choose appropriate counting method
Survey platforms export differently:
- Binary: ODK, SurveyCTO, Qualtrics, RedCap, LimeSurvey
- Concatenated: Google Forms, TypeForm, some Excel/manual entry
Document which format you found and method used!
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os
income_col = 'income' # Adjust as needed
assert income_col in df.columns, f"Column {income_col} not found"
income = df[income_col].dropna()
# Report both parametric and robust statistics
print("Income Distribution Analysis")
print("=" * 50)
print(f"N: {len(income):,}")
print(f"\nCentral Tendency:")
print(f" Mean: ${income.mean():,.2f}")
print(f" Median: ${income.median():,.2f}")
print(f" Trimmed Mean (5%): ${stats.trim_mean(income, 0.05):,.2f}")
print(f"\nDispersion:")
print(f" Std Dev: ${income.std():,.2f}")
print(f" IQR: ${income.quantile(0.75) - income.quantile(0.25):,.2f}")
print(f" MAD: ${stats.median_abs_deviation(income):,.2f}")
print(f"\nDistribution Shape:")
print(f" Skewness: {income.skew():.2f}")
print(f" Kurtosis: {income.kurtosis():.2f}")
# Suggest transformation if highly skewed
if abs(income.skew()) > 2:
print(f"\nHigh skewness detected. Consider log transformation.")
log_income = np.log1p(income) # log(1+x) to handle zeros
print(f" Log-transformed skewness: {log_income.skew():.2f}")
# Percentiles for inequality measures
print(f"\nPercentiles:")
for p in [10, 25, 50, 75, 90, 95, 99]:
print(f" P{p}: ${income.quantile(p/100):,.2f}")
print(f"\nInequality Measures:")
print(f" P90/P10 ratio: {income.quantile(0.9) / income.quantile(0.1):.2f}")
print(f" P90/P50 ratio: {income.quantile(0.9) / income.quantile(0.5):.2f}")
# Visualize with both linear and log scales
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
# Original scale histogram
axes[0].hist(income, bins=50, edgecolor='black', alpha=0.7)
axes[0].set_xlabel('Income ($)')
axes[0].set_ylabel('Frequency')
axes[0].set_title('Income Distribution (Linear Scale)')
# Log scale histogram
axes[1].hist(income, bins=50, edgecolor='black', alpha=0.7)
axes[1].set_xlabel('Income ($)')
axes[1].set_ylabel('Frequency')
axes[1].set_title('Income Distribution (Log Scale)')
axes[1].set_xscale('log')
# Box plot to show outliers
axes[2].boxplot(income, vert=True)
axes[2].set_ylabel('Income ($)')
axes[2].set_title('Income Box Plot')
axes[2].set_yscale('log')
plt.tight_layout()
os.makedirs("./images", exist_ok=True)
plt.savefig("./images/income_distribution.png", dpi=144, bbox_inches="tight")
# Return figure for inline display
return plt.gcf(),
</code>
<lesson>
For skewed data:
1. Always report both mean and median
2. Consider robust statistics (trimmed mean, MAD)
3. Visualize on both linear and log scales
4. Document transformation decisions
</lesson>
</example>
<example context="panel_data" difficulty="advanced">
<description>Descriptive statistics for panel/longitudinal data</description>
<code>
```python
@app.cell
def describe_panel_data(df):
#Panel data requires checking both cross-sectional
# and time-series properties. We examine balance, attrition,
# and within vs. between variation.
import pandas as pd
import numpy as np
# Identify panel structure columns
unit_col = 'unit_id' # Adjust as needed
time_col = 'year' # Adjust as needed
assert {unit_col, time_col}.issubset(df.columns), f"Panel columns not found"
print("PANEL DATA STRUCTURE")
print("=" * 60)
# Basic panel properties
n_units = df[unit_col].nunique()
n_periods = df[time_col].nunique()
n_obs = len(df)
print(f"Units: {n_units:,}")
print(f"Time periods: {n_periods}")
print(f"Total observations: {n_obs:,}")
print(f"Theoretical balanced panel size: {n_units * n_periods:,}")
# Check balance
obs_per_unit = df.groupby(unit_col).size()
is_balanced = (obs_per_unit == n_periods).all()
print(f"\nPanel balance: {'Balanced' if is_balanced else 'Unbalanced'}")
if not is_balanced:
print(f" Units with complete data: {(obs_per_unit == n_periods).sum()} ({(obs_per_unit == n_periods).sum()/n_units*100:.1f}%)")
print(f" Mean observations per unit: {obs_per_unit.mean():.1f}")
print(f" Min observations: {obs_per_unit.min()}")
print(f" Max observations: {obs_per_unit.max()}")
# Attrition analysis
first_period = df[time_col].min()
last_period = df[time_col].max()
units_first = set(df[df[time_col] == first_period][unit_col])
units_last = set(df[df[time_col] == last_period][unit_col])
attrition = len(units_first - units_last)
entry = len(units_last - units_first)
print(f"\nPanel dynamics:")
print(f" Units at start: {len(units_first):,}")
print(f" Units at end: {len(units_last):,}")
print(f" Attrition: {attrition} units ({attrition/len(units_first)*100:.1f}%)")
print(f" Late entry: {entry} units")
# Within vs. between variation for numeric variables
numeric_cols = df.select_dtypes(include=[np.number]).columns
numeric_cols = [c for c in numeric_cols if c not in [unit_col, time_col]]
if numeric_cols:
print(f"\nWITHIN VS. BETWEEN VARIATION")
print("=" * 60)
for col in numeric_cols[:5]: # First 5 numeric variables
# Overall variation
overall_std = df[col].std()
# Between variation (across units)
unit_means = df.groupby(unit_col)[col].mean()
between_std = unit_means.std()
# Within variation (within units over time)
df_demeaned = df[col] - df.groupby(unit_col)[col].transform('mean')
within_std = df_demeaned.std()
print(f"\n{col}:")
print(f" Overall SD: {overall_std:.3f}")
print(f" Between SD: {between_std:.3f} ({between_std/overall_std*100:.1f}% of total)")
print(f" Within SD: {within_std:.3f} ({within_std/overall_std*100:.1f}% of total)")
# Check if mostly cross-sectional or time-series variation
if between_std > within_std * 2:
print(f" → Primarily cross-sectional variation")
elif within_std > between_std * 2:
print(f" → Primarily time-series variation")
else:
print(f" → Substantial both between and within variation")
return obs_per_unit,
1---2name: descriptive-statistics3description: Summary statistics, data exploration, and descriptive analysis. Use for: summary stats, describe data, data exploration, distributions, means, medians, correlations, cross-tabs.4---56<skill_content>78<overview>9Descriptive statistics are the foundation of all statistical analysis. They reveal data structure, quality issues, and patterns that inform subsequent modeling choices. ALWAYS start with descriptive statistics before any inferential or causal analysis.1011This skill ensures you understand your data thoroughly before making statistical claims.12</overview>1314<mandatory_requirements>1516<requirement priority="critical">17 <name>Complete Summary Statistics</name>18 <description>Generate comprehensive summary statistics for all variables</description>19 <rationale>You cannot choose appropriate methods without understanding distributions, missingness, and ranges (Tukey, 1977: "Exploratory Data Analysis")</rationale>20 <consequence>Using wrong methods (e.g., linear regression on bounded outcomes, t-tests on skewed distributions)</consequence>21</requirement>2223<requirement priority="critical">24 <name>Missing Data Analysis</name>25 <description>Report missingness patterns for all variables used in analysis. NEVER fabricate or impute values to "fill in" missing data without explicit justification and documentation. Report actual missing data patterns, not synthetic replacements</description>26 <rationale>Missing data mechanisms (MCAR/MAR/MNAR) affect validity of all subsequent analyses (Little & Rubin, 2002). Fabricating missing values masks true data limitations and can introduce undetectable bias. See core-methodology skill for data authenticity requirements</rationale>27 <consequence>Biased estimates if missingness is informative. Fabricated values presented as real data invalidate findings</consequence>28</requirement>2930<requirement priority="critical">31 <name>Distribution Visualization</name>32 <description>Plot distributions for all key variables</description>33 <rationale>Summary statistics can hide bimodality, outliers, and skewness (Anscombe's Quartet demonstrates this)</rationale>34 <consequence>Inappropriate method selection and violated assumptions</consequence>35</requirement>3637<requirement priority="high">38 <name>Sample Size Reporting</name>39 <description>Report N for overall sample and by groups</description>40 <rationale>Statistical power and precision depend on sample size</rationale>41 <consequence>Underpowered analyses or false precision claims</consequence>42</requirement>4344</mandatory_requirements>4546<thinking_process>47When implementing descriptive statistics:481. Check data types (numeric, categorical, dates)492. Inspect multi-select question formats (binary columns vs concatenated strings)503. Generate appropriate summaries for each type514. Identify missingness patterns525. Visualize distributions to detect issues536. Check for data quality problems547. Report everything clearly55</thinking_process>5657<implementation_pattern>5859<code_template>60```python61@app.cell62def comprehensive_descriptive_stats(df):63 #Following Tukey's EDA principles, we examine the data from multiple64 # angles before any modeling. This reveals quality issues, appropriate methods,65 # and potential problems that could invalidate subsequent analyses.6667 import pandas as pd68 import numpy as np69 import matplotlib.pyplot as plt70 import seaborn as sns71 import os7273 # MANDATORY: Dataset overview74 print("=" * 60)75 print("DATASET OVERVIEW")76 print("=" * 60)77 print(f"Observations: {len(df):,}")78 print(f"Variables: {len(df.columns)}")79 print(f"\nVariable Types:")80 print(df.dtypes.value_counts())8182 # MANDATORY: Identify numeric and categorical columns83 numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()84 categorical_cols = df.select_dtypes(include=['object', 'category']).columns.tolist()8586 # MANDATORY: Summary statistics for numeric variables87 if numeric_cols:88 print("\n" + "=" * 60)89 print("NUMERIC VARIABLES SUMMARY")90 print("=" * 60)91 summary = df[numeric_cols].describe(percentiles=[.01, .05, .25, .5, .75, .95, .99])92 print(summary.round(3))9394 # MANDATORY: Check for outliers using IQR method95 print("\n" + "=" * 60)96 print("OUTLIER DETECTION (IQR Method)")97 print("=" * 60)98 for col in numeric_cols[:10]: # Limit to first 10 for brevity99 Q1 = df[col].quantile(0.25)100 Q3 = df[col].quantile(0.75)101 IQR = Q3 - Q1102 outliers = df[(df[col] < Q1 - 1.5*IQR) | (df[col] > Q3 + 1.5*IQR)][col]103 if len(outliers) > 0:104 print(f"{col}: {len(outliers)} outliers ({len(outliers)/len(df)*100:.1f}%)")105106 # MANDATORY: Missing data analysis107 print("\n" + "=" * 60)108 print("MISSING DATA ANALYSIS")109 print("=" * 60)110 missing = df.isnull().sum()111 missing_pct = (missing / len(df)) * 100112 missing_df = pd.DataFrame({113 'Missing_Count': missing,114 'Missing_Percentage': missing_pct115 })116 missing_df = missing_df[missing_df['Missing_Count'] > 0].sort_values('Missing_Count', ascending=False)117118 if len(missing_df) > 0:119 print(missing_df.round(2))120 print(f"\nTotal rows with any missing: {df.isnull().any(axis=1).sum()} ({df.isnull().any(axis=1).sum()/len(df)*100:.1f}%)")121 else:122 print("No missing values detected")123124 # MANDATORY: Categorical variables summary125 if categorical_cols:126 print("\n" + "=" * 60)127 print("CATEGORICAL VARIABLES SUMMARY")128 print("=" * 60)129 for col in categorical_cols[:10]: # Limit to first 10130 n_unique = df[col].nunique()131 print(f"\n{col}: {n_unique} unique values")132 if n_unique <= 20:133 print(df[col].value_counts().head(10))134135 # MANDATORY: Distribution plots for numeric variables136 if numeric_cols:137 n_cols = min(len(numeric_cols), 9) # Max 9 subplots138 n_rows = (n_cols + 2) // 3139 n_cols_plot = min(3, n_cols)140141 fig, axes = plt.subplots(n_rows, n_cols_plot, figsize=(15, n_rows*4))142 axes = axes.flatten() if n_rows > 1 else [axes] if n_cols == 1 else axes143144 for i, col in enumerate(numeric_cols[:9]):145 data = df[col].dropna()146147 # Histogram with KDE148 axes[i].hist(data, bins=30, density=True, alpha=0.7, edgecolor='black')149 axes[i].set_title(f'{col}\nSkew: {data.skew():.2f}, Kurt: {data.kurtosis():.2f}')150 axes[i].set_xlabel(col)151 axes[i].set_ylabel('Density')152153 # Add KDE if enough data154 if len(data) > 10:155 from scipy import stats156 kde = stats.gaussian_kde(data)157 x_range = np.linspace(data.min(), data.max(), 100)158 axes[i].plot(x_range, kde(x_range), 'r-', linewidth=2)159160 # Hide unused subplots161 for i in range(len(numeric_cols[:9]), len(axes)):162 axes[i].set_visible(False)163164 plt.tight_layout()165 os.makedirs("./images", exist_ok=True)166 plt.savefig("./images/distributions_overview.png", dpi=144, bbox_inches="tight")167 print(f"\nSUCCESS: Distribution plots saved to ./images/distributions_overview.png")168169 # Save figure for inline display170 fig = plt.gcf()171 return summary, fig,172 else:173 return None, None,174```175</code_template>176177</implementation_pattern>178179<examples>180181<example context="clean_data" difficulty="basic">182<description>Basic descriptive statistics for clean survey data</description>183<code>184```python185@app.cell186def describe_survey_data(df):187 #Survey data requires checking for response patterns,188 # missing data from non-response, and scale distributions to189 # identify potential response biases or data entry errors.190191 import pandas as pd192 import numpy as np193194 # Check response scales195 likert_cols = [col for col in df.columns if 'rating' in col.lower() or 'scale' in col.lower()]196197 if likert_cols:198 print("Likert Scale Variables:")199 for col in likert_cols:200 print(f"\n{col}:")201 print(f" Range: {df[col].min()} - {df[col].max()}")202 print(f" Mean: {df[col].mean():.2f}, Median: {df[col].median():.2f}")203 print(f" % at ceiling: {(df[col] == df[col].max()).mean()*100:.1f}%")204 print(f" % at floor: {(df[col] == df[col].min()).mean()*100:.1f}%")205206 # Check for response patterns (straight-lining)207 if len(likert_cols) > 3:208 straight_line = (df[likert_cols].std(axis=1) == 0).sum()209 print(f"\nStraight-lining detected: {straight_line} respondents ({straight_line/len(df)*100:.1f}%)")210211 return None,212```213</code>214<output_interpretation>215Look for:216- Ceiling/floor effects (>15% at extremes suggests scale problems)217- Straight-lining (>5% suggests inattentive respondents)218- Unexpected ranges (values outside scale bounds indicate data errors)219</output_interpretation>220</example>221222<example context="multiselect_questions" difficulty="basic">223<description>Handling multi-select survey questions in different formats</description>224<code>225```python226@app.cell227def inspect_multiselect_format(df):228 """Multi-select questions appear in two common formats depending on survey platform.229 ALWAYS inspect before analyzing to avoid counting errors.230231 Format 1 (Binary/Dummy): ODK, SurveyCTO, Qualtrics, RedCap232 - One column per option with values {0, 1, NaN}233 - Example: 'activities/Business', 'activities/Farming'234235 Format 2 (Concatenated): Google Forms, Excel, TypeForm236 - One column with space/comma-separated text237 - Example: 'activities' = "Business Farming CHE"238 """239240 import pandas as pd241242 # STEP 1: Identify potential multi-select columns243 # Look for column name patterns244 potential_multiselect = [col for col in df.columns245 if 'select all' in col.lower()246 or col.count('/') > 0 # Binary format indicator247 or 'activities' in col.lower()]248249 print("=" * 70)250 print("MULTI-SELECT QUESTION FORMAT INSPECTION")251 print("=" * 70)252253 # STEP 2: For each potential column, check format254 for col in potential_multiselect[:5]: # Check first 5255 print(f"\nColumn: {col}")256 print(f"Data type: {df[col].dtype}")257258 # Check unique values259 unique_vals = df[col].dropna().unique()260 n_unique = len(unique_vals)261262 print(f"Unique values: {n_unique}")263264 # DIAGNOSTIC: Is this binary format?265 if df[col].dtype in ['float64', 'int64'] and set(unique_vals).issubset({0, 1}):266 print("→ FORMAT: Binary/Dummy variable (count where value = 1)")267 missing = df[col].isna().sum()268 zeros = (df[col] == 0).sum()269 ones = (df[col] == 1).sum()270 print(f" Missing: {missing} ({missing/len(df)*100:.1f}%)")271 print(f" 0 (not selected): {zeros}")272 print(f" 1 (selected): {ones}")273274 # DIAGNOSTIC: Is this concatenated format?275 elif df[col].dtype == 'object':276 print("→ FORMAT: Concatenated string (parse with .str.contains)")277 missing = df[col].isna().sum()278 print(f" Missing: {missing} ({missing/len(df)*100:.1f}%)")279 print(f" Sample values:")280 for val in df[col].dropna().head(3):281 val_str = str(val)[:70] + "..." if len(str(val)) > 70 else str(val)282 print(f" - {val_str}")283 else:284 print(f"→ UNKNOWN FORMAT - Manual inspection needed")285286 return None,287```288289```python290@app.cell291def analyze_multiselect_binary(df):292 """Count responses for binary/dummy format multi-select questions.293 Common in ODK/SurveyCTO exports.294 """295296 import pandas as pd297298 # Identify all binary columns for a multi-select question299 question_prefix = "What are your main income-generating activities? (select all that apply)/"300 activity_cols = [col for col in df.columns if col.startswith(question_prefix)]301302 print("=" * 70)303 print("BINARY FORMAT MULTI-SELECT ANALYSIS")304 print("=" * 70)305 print(f"Total respondents: {len(df)}")306 print()307308 results = []309 for col in activity_cols:310 activity_name = col.replace(question_prefix, "")311312 # CRITICAL: Count only where value = 1 (NOT .notna() which counts 0s too!)313 count = (df[col] == 1).sum()314 percentage = (count / len(df)) * 100315316 results.append({317 'Activity': activity_name,318 'N': count,319 'Percentage': percentage320 })321322 results_df = pd.DataFrame(results).sort_values('N', ascending=False)323 print(results_df.to_string(index=False))324325 # Calculate average selections per person326 total_selections = sum(r['N'] for r in results)327 avg_per_person = total_selections / len(df)328 print(f"\nAverage selections per person: {avg_per_person:.2f}")329330 return results_df,331```332333```python334@app.cell335def analyze_multiselect_concatenated(df):336 """Parse concatenated string format multi-select questions.337 Common in Google Forms/Excel exports.338 """339340 import pandas as pd341342 col = 'main_income_generating' # Adjust column name343 assert col in df.columns, f"Column {col} not found"344345 # Define possible options to search for346 possible_activities = [347 'Community Health Entrepreneur (CHE)',348 'Community Health Promoter (CHP)',349 'Business',350 'Farming',351 'Casual work',352 'Teaching',353 'Tailoring',354 'Other'355 ]356357 print("=" * 70)358 print("CONCATENATED STRING MULTI-SELECT ANALYSIS")359 print("=" * 70)360 print(f"Total respondents: {len(df)}")361 print(f"Missing: {df[col].isna().sum()}")362 print()363364 results = []365 for activity in possible_activities:366 # CRITICAL: Use regex=False for literal matching (parentheses are special in regex!)367 count = df[col].str.contains(activity, na=False, regex=False).sum()368 percentage = (count / len(df)) * 100369370 if count > 0: # Only include if found371 results.append({372 'Activity': activity,373 'N': count,374 'Percentage': percentage375 })376377 results_df = pd.DataFrame(results).sort_values('N', ascending=False)378 print(results_df.to_string(index=False))379380 # Calculate average selections per person381 total_selections = sum(r['N'] for r in results)382 avg_per_person = total_selections / len(df)383 print(f"\nAverage selections per person: {avg_per_person:.2f}")384385 return results_df,386```387</code>388<output_interpretation>389**Binary format**:390- Value counts show {0, 1} → Use `(df[col] == 1).sum()` to count selections391- Using `.notna().sum()` counts BOTH 0s and 1s → Wrong totals (will show 100% for all)392- Missing values (NaN) represent true non-response, not "not selected"393394**Concatenated format**:395- Sample values show multiple activities in one string396- MUST use `regex=False` in `.str.contains()` if options have special chars like parentheses397- Delimiter varies (space, comma, semicolon) - inspect samples first398- Person can select multiple activities → counts will sum to > 100%399400**Common error**: Using `.notna()` on binary format → counts all non-missing as "selected"401**Fix**: Always use `== 1` for binary, `.str.contains()` for strings402</output_interpretation>403<best_practice>404ALWAYS run format inspection first:4051. Check data type (float/int = binary, object = string)4062. Check unique values (`value_counts(dropna=False)`)4073. View sample rows to confirm structure4084. Choose appropriate counting method409410Survey platforms export differently:411- **Binary**: ODK, SurveyCTO, Qualtrics, RedCap, LimeSurvey412- **Concatenated**: Google Forms, TypeForm, some Excel/manual entry413414Document which format you found and method used!415</best_practice>416</example>417418<example context="skewed_data" difficulty="intermediate">419<description>Handling highly skewed income data</description>420<code>421```python422@app.cell423def describe_skewed_income(df):424 #Income data is typically right-skewed with outliers.425 # We report both standard statistics and robust alternatives,426 # and consider log transformation for modeling.427428 import pandas as pd429 import numpy as np430 import matplotlib.pyplot as plt431 import os432433 income_col = 'income' # Adjust as needed434 assert income_col in df.columns, f"Column {income_col} not found"435436 income = df[income_col].dropna()437438 # Report both parametric and robust statistics439 print("Income Distribution Analysis")440 print("=" * 50)441 print(f"N: {len(income):,}")442 print(f"\nCentral Tendency:")443 print(f" Mean: ${income.mean():,.2f}")444 print(f" Median: ${income.median():,.2f}")445 print(f" Trimmed Mean (5%): ${stats.trim_mean(income, 0.05):,.2f}")446447 print(f"\nDispersion:")448 print(f" Std Dev: ${income.std():,.2f}")449 print(f" IQR: ${income.quantile(0.75) - income.quantile(0.25):,.2f}")450 print(f" MAD: ${stats.median_abs_deviation(income):,.2f}")451452 print(f"\nDistribution Shape:")453 print(f" Skewness: {income.skew():.2f}")454 print(f" Kurtosis: {income.kurtosis():.2f}")455456 # Suggest transformation if highly skewed457 if abs(income.skew()) > 2:458 print(f"\nHigh skewness detected. Consider log transformation.")459 log_income = np.log1p(income) # log(1+x) to handle zeros460 print(f" Log-transformed skewness: {log_income.skew():.2f}")461462 # Percentiles for inequality measures463 print(f"\nPercentiles:")464 for p in [10, 25, 50, 75, 90, 95, 99]:465 print(f" P{p}: ${income.quantile(p/100):,.2f}")466467 print(f"\nInequality Measures:")468 print(f" P90/P10 ratio: {income.quantile(0.9) / income.quantile(0.1):.2f}")469 print(f" P90/P50 ratio: {income.quantile(0.9) / income.quantile(0.5):.2f}")470471 # Visualize with both linear and log scales472 fig, axes = plt.subplots(1, 3, figsize=(15, 5))473474 # Original scale histogram475 axes[0].hist(income, bins=50, edgecolor='black', alpha=0.7)476 axes[0].set_xlabel('Income ($)')477 axes[0].set_ylabel('Frequency')478 axes[0].set_title('Income Distribution (Linear Scale)')479480 # Log scale histogram481 axes[1].hist(income, bins=50, edgecolor='black', alpha=0.7)482 axes[1].set_xlabel('Income ($)')483 axes[1].set_ylabel('Frequency')484 axes[1].set_title('Income Distribution (Log Scale)')485 axes[1].set_xscale('log')486487 # Box plot to show outliers488 axes[2].boxplot(income, vert=True)489 axes[2].set_ylabel('Income ($)')490 axes[2].set_title('Income Box Plot')491 axes[2].set_yscale('log')492493 plt.tight_layout()494 os.makedirs("./images", exist_ok=True)495 plt.savefig("./images/income_distribution.png", dpi=144, bbox_inches="tight")496497 # Return figure for inline display498 return plt.gcf(),499```500</code>501<lesson>502For skewed data:5031. Always report both mean and median5042. Consider robust statistics (trimmed mean, MAD)5053. Visualize on both linear and log scales5064. Document transformation decisions507</lesson>508</example>509510<example context="panel_data" difficulty="advanced">511<description>Descriptive statistics for panel/longitudinal data</description>512<code>513```python514@app.cell515def describe_panel_data(df):516 #Panel data requires checking both cross-sectional517 # and time-series properties. We examine balance, attrition,518 # and within vs. between variation.519520 import pandas as pd521 import numpy as np522523 # Identify panel structure columns524 unit_col = 'unit_id' # Adjust as needed525 time_col = 'year' # Adjust as needed526527 assert {unit_col, time_col}.issubset(df.columns), f"Panel columns not found"528529 print("PANEL DATA STRUCTURE")530 print("=" * 60)531532 # Basic panel properties533 n_units = df[unit_col].nunique()534 n_periods = df[time_col].nunique()535 n_obs = len(df)536537 print(f"Units: {n_units:,}")538 print(f"Time periods: {n_periods}")539 print(f"Total observations: {n_obs:,}")540 print(f"Theoretical balanced panel size: {n_units * n_periods:,}")541542 # Check balance543 obs_per_unit = df.groupby(unit_col).size()544 is_balanced = (obs_per_unit == n_periods).all()545546 print(f"\nPanel balance: {'Balanced' if is_balanced else 'Unbalanced'}")547 if not is_balanced:548 print(f" Units with complete data: {(obs_per_unit == n_periods).sum()} ({(obs_per_unit == n_periods).sum()/n_units*100:.1f}%)")549 print(f" Mean observations per unit: {obs_per_unit.mean():.1f}")550 print(f" Min observations: {obs_per_unit.min()}")551 print(f" Max observations: {obs_per_unit.max()}")552553 # Attrition analysis554 first_period = df[time_col].min()555 last_period = df[time_col].max()556557 units_first = set(df[df[time_col] == first_period][unit_col])558 units_last = set(df[df[time_col] == last_period][unit_col])559560 attrition = len(units_first - units_last)561 entry = len(units_last - units_first)562563 print(f"\nPanel dynamics:")564 print(f" Units at start: {len(units_first):,}")565 print(f" Units at end: {len(units_last):,}")566 print(f" Attrition: {attrition} units ({attrition/len(units_first)*100:.1f}%)")567 print(f" Late entry: {entry} units")568569 # Within vs. between variation for numeric variables570 numeric_cols = df.select_dtypes(include=[np.number]).columns571 numeric_cols = [c for c in numeric_cols if c not in [unit_col, time_col]]572573 if numeric_cols:574 print(f"\nWITHIN VS. BETWEEN VARIATION")575 print("=" * 60)576577 for col in numeric_cols[:5]: # First 5 numeric variables578 # Overall variation579 overall_std = df[col].std()580581 # Between variation (across units)582 unit_means = df.groupby(unit_col)[col].mean()583 between_std = unit_means.std()584585 # Within variation (within units over time)586 df_demeaned = df[col] - df.groupby(unit_col)[col].transform('mean')587 within_std = df_demeaned.std()588589 print(f"\n{col}:")590 print(f" Overall SD: {overall_std:.3f}")591 print(f" Between SD: {between_std:.3f} ({between_std/overall_std*100:.1f}% of total)")592 print(f" Within SD: {within_std:.3f} ({within_std/overall_std*100:.1f}% of total)")593594 # Check if mostly cross-sectional or time-series variation595 if between_std > within_std * 2:596 print(f" → Primarily cross-sectional variation")597 elif within_std > between_std * 2:598 print(f" → Primarily time-series variation")599 else:600 print(f" → Substantial both between and within variation")601602 return obs_per_unit,603```604</code>605<best_practice>606For panel data:6071. Always check balance and attrition patterns6082. Decompose variation (within/between) to inform model choice6093. Consider if fixed effects will absorb key variables6104. Document entry/exit patterns for external validity611</best_practice>612</example>613614</examples>615616<common_mistakes>617618<mistake severity="critical">619 <what>Using .notna() or .count() to tally binary multi-select questions</what>620 <consequence>Counts both 0s and 1s as "selected" → inflated percentages (often 100% for all options)</consequence>621 <prevention>ALWAYS use (df[col] == 1).sum() for binary format. Inspect format first with value_counts(dropna=False)</prevention>622 <real_world_example>Builder activities showing 100% participation in all activities because code counted zeros as selections</real_world_example>623</mistake>624625<mistake severity="high">626 <what>Using regex=True (default) on .str.contains() with special characters</what>627 <consequence>Parentheses, brackets, dots treated as regex patterns → missing matches</consequence>628 <prevention>Use regex=False for literal string matching in concatenated multi-select fields</prevention>629 <real_world_example>CHE and CHP activities not detected because parentheses in "Community Health Entrepreneur (CHE)" treated as regex grouping</real_world_example>630</mistake>631632<mistake severity="high">633 <what>Computing means without checking distributions</what>634 <consequence>Mean is misleading for skewed data (e.g., income)</consequence>635 <prevention>Always visualize distributions and report median alongside mean</prevention>636</mistake>637638<mistake severity="high">639 <what>Ignoring missing data patterns</what>640 <consequence>Biased estimates if missingness is not random</consequence>641 <prevention>Test for patterns in missingness (e.g., missing income correlated with age)</prevention>642</mistake>643644<mistake severity="medium">645 <what>Not checking data types before analysis</what>646 <consequence>Treating categorical as numeric or vice versa</consequence>647 <prevention>Explicitly check dtypes and convert as needed</prevention>648</mistake>649650<mistake severity="medium">651 <what>Using sample statistics on population data</what>652 <consequence>Incorrect uncertainty quantification</consequence>653 <prevention>Distinguish between sample and population analyses</prevention>654</mistake>655656</common_mistakes>657658<interpretation_guide>659660<interpreting_results>661- Mean vs. Median difference > 20% → Consider skewness662- Kurtosis > 3 → Heavy tails, expect outliers663- Missing > 10% → Need missing data strategy664- Between-SD > Within-SD → Fixed effects may not be appropriate665</interpreting_results>666667<red_flags>668- Skewness > 2 or < -2 → Distribution far from normal669- Multiple modes in histogram → Distinct subpopulations670- Straight-lining in surveys → Data quality issues671- Sudden changes in panel balance → Selection bias risk672</red_flags>673674<next_steps>675- All checks pass → Proceed to inferential analysis676- Skewed outcomes → Consider transformations or GLM677- Missing data → Implement appropriate handling strategy678- Outliers detected → Robustness checks needed679</next_steps>680681</interpretation_guide>682683<references>684<paper>Tukey, J.W. (1977). "Exploratory Data Analysis." Addison-Wesley. Foundation of EDA principles.</paper>685<paper>Little, R.J.A. & Rubin, D.B. (2002). "Statistical Analysis with Missing Data." Wiley. Missing data mechanisms.</paper>686<paper>Anscombe, F.J. (1973). "Graphs in Statistical Analysis." American Statistician. Why visualization matters.</paper>687</references>688689</skill_content>