
People Analytics
- 179 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Measure headcount, hiring funnel, retention, and workforce KPIs when making staffing, org design, and people-ops decisions for scaling product or SaaS teams.
About
people-analytics from borghei/claude-skills supports workforce and HR analytics for growing organizations. It helps define people KPIs, analyze hiring funnels and retention, build monitoring dashboards, and translate headcount and org health data into actionable staffing and people-ops decisions for SaaS and product teams.
- Workforce and headcount KPI definitions
- Hiring funnel and time-to-fill analysis
- Retention and attrition trend monitoring
- Org capacity and team health dashboards
- Data pipeline guidance for HR metrics
People Analytics by the numbers
- 179 all-time installs (skills.sh)
- Ranked #694 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/borghei/claude-skills --skill people-analyticsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 179 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Measure headcount, hiring funnel, retention, and workforce KPIs when making staffing, org design, and people-ops decisions for scaling product or SaaS teams.
Files
People Analytics
The agent operates as a senior people analytics partner, translating workforce data into actionable insights using statistical modeling, segmentation analysis, and data governance best practices.
Workflow
1. Frame the question -- Clarify the business question with the HR or business stakeholder. Examples: "Why is Sales attrition 2x the company average?" or "Are we paying equitably across gender?" Define the success metric for the analysis. 2. Assess data readiness -- Identify required data sources (HRIS, ATS, survey platform, payroll). Check for completeness, recency, and quality. Flag any gaps before proceeding. 3. Analyze -- Apply the appropriate method from the analytics toolkit (descriptive stats, regression, classification, segmentation). Document assumptions and limitations. 4. Validate findings -- Sense-check results with domain experts (HRBPs, managers). Test for statistical significance and practical significance. Check predictive models for bias across protected groups. 5. Recommend -- Translate findings into 2-3 specific, actionable recommendations with expected impact and cost. 6. Deliver and monitor -- Present insights using the dashboard framework. Set up ongoing monitoring for key metrics with alert thresholds.
Checkpoint: After step 2, confirm that all data has been anonymized or aggregated to comply with privacy policy before analysis begins.
Analytics Maturity Model
| Level | Name | Capabilities | Typical Questions Answered |
|---|---|---|---|
| 1 | Operational Reporting | Headcount, compliance, ad-hoc queries | "How many people do we have?" |
| 2 | Advanced Reporting | Dashboards, trends, benchmarking, segmentation | "How has attrition changed by quarter?" |
| 3 | Analytics | Statistical analysis, correlation, root cause | "What drives attrition in Sales?" |
| 4 | Predictive | Turnover prediction, performance modeling, risk scoring | "Who is likely to leave in the next 6 months?" |
| 5 | Prescriptive | Automated recommendations, real-time interventions | "What should we do to retain this person?" |
Core HR Metrics
Workforce Metrics
| Metric | Formula | Benchmark |
|---|---|---|
| Turnover Rate | (Separations / Avg HC) x 100 | 10-15% |
| Retention Rate | (Retained / Starting HC) x 100 | 85-90% |
| Time to Fill | Days from req open to offer accept | 30-45 days |
| Cost per Hire | Total recruiting cost / Hires | $3-5K |
| Regrettable Turnover | Regrettable exits / Total exits | < 30% |
Performance Metrics
| Metric | Formula | Benchmark |
|---|---|---|
| High Performers | % rated top tier | 15-20% |
| Goal Completion | Goals achieved / Goals set | 80%+ |
| Promotion Rate | Promotions / Headcount | 8-12% |
Engagement Metrics
| Metric | Formula | Benchmark |
|---|---|---|
| eNPS | Promoters % - Detractors % | 20-40 |
| Engagement Score | Survey composite (1-100) | 70%+ |
| Absenteeism | Absent days / Work days | < 3% |
Turnover Prediction Model
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
def build_turnover_model(employee_data: pd.DataFrame) -> dict:
"""
Build and evaluate a turnover prediction model.
Input: DataFrame with columns for features + 'left_company' (0/1).
Output: dict with model, feature importance, and evaluation metrics.
"""
features = [
'tenure_months', 'salary_ratio_to_market', 'performance_rating',
'months_since_last_promotion', 'manager_tenure', 'team_size',
'engagement_score', 'training_hours_ytd', 'projects_completed'
]
X = employee_data[features]
y = employee_data['left_company']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
report = classification_report(y_test, y_pred, output_dict=True)
importance = (
pd.DataFrame({'feature': features, 'importance': model.feature_importances_})
.sort_values('importance', ascending=False)
)
return {'model': model, 'importance': importance, 'evaluation': report}
def score_flight_risk(model, current_employees: pd.DataFrame) -> pd.DataFrame:
"""
Score current employees for flight risk.
Returns DataFrame with employee_id, flight_risk_score (0-1), and risk_level.
"""
probabilities = model.predict_proba(current_employees[model.feature_names_in_])[:, 1]
risk_levels = pd.cut(
probabilities,
bins=[0, 0.25, 0.50, 0.75, 1.0],
labels=['Low', 'Medium', 'High', 'Critical']
)
return pd.DataFrame({
'employee_id': current_employees['employee_id'],
'flight_risk_score': probabilities.round(3),
'risk_level': risk_levels
}).sort_values('flight_risk_score', ascending=False)Example: Sales Attrition Root-Cause Analysis
QUESTION
Sales voluntary turnover is 22% vs 12% company average. Why?
DATA
Source: HRIS + engagement survey + exit interviews (n=45 exits, trailing 12 mo)
ANALYSIS
Segmentation by tenure band:
< 1 yr: 35% of exits (onboarding/ramp issues)
1-2 yr: 40% of exits (comp dissatisfaction + career path)
2+ yr: 25% of exits (manager relationship)
Regression on exit survey scores (n=38 respondents):
Top drivers of intent-to-leave:
1. "I am paid fairly" (beta = -0.42, p < 0.01)
2. "I see a career path here" (beta = -0.31, p < 0.01)
3. "My manager supports my development" (beta = -0.28, p < 0.05)
Compensation benchmark:
Sales IC3 compa-ratio: 0.88 (12% below midpoint)
Sales IC2 compa-ratio: 0.91 (9% below midpoint)
Rest of company average: 0.98
FINDINGS
1. Sales comp is significantly below market, especially at IC2-IC3
2. No defined career ladder for Sales ICs beyond IC3
3. New hires (< 1 yr) leaving due to unrealistic ramp expectations
RECOMMENDATIONS
1. Market adjustment: Bring Sales IC2-IC3 to 95th percentile compa-ratio ($180K budget)
2. Publish a Sales career ladder through IC5 with clear promotion criteria
3. Redesign onboarding: extend ramp period from 30 to 90 days with milestone targets
EXPECTED IMPACT
Reduce Sales attrition from 22% to 14-16% within 12 months
ROI: $180K adjustment saves ~$450K in replacement costs (10 fewer exits x $45K/hire)Pay Equity Analysis
import pandas as pd
import statsmodels.api as sm
def analyze_pay_equity(employee_data: pd.DataFrame) -> dict:
"""
Conduct pay equity analysis controlling for legitimate pay factors.
Returns raw gap, adjusted gap, model fit, and employees flagged for review.
"""
# Raw gap
avg_by_gender = employee_data.groupby('gender')['salary'].mean()
raw_gap = (avg_by_gender['Female'] - avg_by_gender['Male']) / avg_by_gender['Male']
# Adjusted gap (control for level, tenure, performance, location)
controls = pd.get_dummies(
employee_data[['job_level', 'tenure_years', 'performance_rating', 'department', 'location']],
drop_first=True
)
controls = sm.add_constant(controls)
controls['is_female'] = (employee_data['gender'] == 'Female').astype(int)
model = sm.OLS(employee_data['salary'], controls).fit()
adjusted_gap = model.params['is_female']
# Flag outliers (residual > 2 std dev)
employee_data['predicted'] = model.predict(controls)
employee_data['residual'] = employee_data['salary'] - employee_data['predicted']
threshold = 2 * employee_data['residual'].std()
flagged = employee_data[abs(employee_data['residual']) > threshold]
return {
'raw_gap_pct': round(raw_gap * 100, 1),
'adjusted_gap_usd': round(adjusted_gap, 0),
'model_r_squared': round(model.rsquared, 3),
'employees_flagged': len(flagged),
'flagged_details': flagged[['employee_id', 'salary', 'predicted', 'residual']]
}Engagement Survey Analysis
1. Calculate response rate -- Target 80%+ for statistical validity. Flag departments below 60%. 2. Compute category scores -- Average Likert responses by category (Manager, Growth, Culture, Compensation). Compare to prior period. 3. Run driver analysis -- Regress category scores against overall engagement to identify which categories have the highest impact on engagement. 4. Segment -- Break results by department, level, tenure band, and location. Identify where scores diverge most from company average. 5. Prioritize -- Plot categories on a 2x2 matrix (Impact vs Score). "High impact, low score" quadrant = priority action areas.
Checkpoint: Suppress results for any segment with fewer than 5 respondents to protect anonymity.
DEI Metrics Framework
| Domain | Metrics | Data Source |
|---|---|---|
| Representation | Gender / ethnicity distribution by level | HRIS |
| Pay equity | Raw gap, adjusted gap (controlled regression) | Payroll + HRIS |
| Progression | Promotion rates by demographic group | HRIS |
| Hiring | Offer and accept rates by demographic group | ATS |
| Inclusion | Inclusion index, belonging score, psychological safety | Survey |
Data Governance Checklist
Before starting any people analytics project:
- [ ] Business question and purpose clearly documented
- [ ] Data minimization applied (only collect what is needed)
- [ ] Privacy impact assessment completed
- [ ] Anonymization or aggregation applied where possible
- [ ] Predictive models tested for bias across protected groups
- [ ] Role-based access controls implemented
- [ ] Data retention policy defined
- [ ] Employee communication planned (transparency principle)
Reference Materials
references/hr_metrics.md- Complete HR metrics guidereferences/predictive_models.md- Predictive modeling approachesreferences/survey_design.md- Survey methodologyreferences/data_ethics.md- Ethical analytics practices
Scripts
# Analyze engagement survey results with driver analysis
python scripts/survey_analyzer.py --file survey_results.csv
python scripts/survey_analyzer.py --file survey_results.csv --prior prior_survey.csv --json
# Score attrition risk from employee data
python scripts/attrition_predictor.py --file employees.csv
python scripts/attrition_predictor.py --file employees.csv --threshold 0.7 --json
# Workforce headcount planning calculations
python scripts/headcount_planner.py --file workforce.csv --growth 0.15 --attrition 0.12
python scripts/headcount_planner.py --file workforce.csv --growth 0.15 --attrition 0.12 --jsonTroubleshooting
| Problem | Root Cause | Resolution |
|---|---|---|
| Low survey response rate (< 70%) | Survey fatigue, lack of trust in anonymity, or no visible action from prior surveys | Shorten survey to 15-20 questions max; communicate anonymity safeguards clearly; publish and act on top 3 findings from prior survey before launching next one |
| Attrition model produces too many false positives | Overfitting on historical data, missing key features, or class imbalance | Add regularization; use SMOTE or class weights to handle imbalance; validate with cross-validation not just train/test split; include manager quality and comp-ratio as features |
| Stakeholders distrust analytics findings | Results contradict lived experience, or methodology is opaque | Present methodology transparently; validate findings with HRBPs before publishing; use confidence intervals not point estimates; start with descriptive analytics to build trust before predictive |
| Data quality issues across HRIS sources | Inconsistent coding, missing fields, stale records, or duplicate entries | Establish data governance council; define data owners per field; run quarterly data quality audits; build automated validation checks at ingestion |
| Privacy concerns block analysis | Insufficient anonymization, no consent framework, or regulatory gaps | Apply k-anonymity (minimum group size of 5); conduct privacy impact assessment before each project; engage Legal early; use aggregated data when individual-level is not required |
| Engagement scores are flat despite interventions | Measuring wrong drivers, action plans not executed, or survey is too generic | Run driver analysis to identify high-impact low-score areas; assign action owners with quarterly check-ins; customize survey questions by department or function |
| Leadership does not act on insights | Insights are too academic, lack business framing, or arrive too late | Lead with business impact (revenue, cost, risk); limit recommendations to 2-3 with clear owners and timelines; deliver insights within 2 weeks of data collection |
Success Criteria
| Dimension | Metric | Target | Measurement |
|---|---|---|---|
| Data Quality | HRIS data completeness | > 95% of required fields populated | Quarterly data audit report |
| Data Quality | Data freshness | All records updated within 30 days | HRIS last-modified timestamps |
| Adoption | Stakeholder usage of dashboards | > 70% of HRBPs and VPs access monthly | Dashboard analytics / login tracking |
| Adoption | Insight-to-action rate | > 60% of recommendations result in initiatives | Quarterly tracking of recommendation outcomes |
| Accuracy | Attrition prediction precision | > 70% precision at 50% recall | Model evaluation against actuals (6-month lag) |
| Accuracy | Survey driver analysis validity | Top 3 drivers validated by qualitative data | Cross-reference with exit interviews and focus groups |
| Impact | Regrettable attrition reduction | 10-20% reduction within 12 months of intervention | HRIS voluntary termination data, regrettable flag |
| Impact | Time from question to insight | < 2 weeks for standard analyses | Request-to-delivery tracking |
| Compliance | Privacy incidents | Zero breaches of anonymity thresholds | Audit log of all queries; minimum group size enforcement |
| Maturity | Analytics maturity level progression | Advance 1 level per 12-18 months | Self-assessment against the Analytics Maturity Model |
Scope & Limitations
In Scope:
- Workforce descriptive analytics: headcount, turnover, retention, demographics, tenure distribution
- Engagement survey design, analysis, driver identification, and benchmarking
- Attrition risk scoring using rule-based and statistical methods (standard library only)
- Pay equity analysis: raw gap, controlled gap, outlier flagging
- DEI metrics: representation, progression rates, hiring funnel equity
- Workforce planning: headcount forecasting, scenario modeling, gap analysis
- Dashboard design and KPI framework recommendations
Out of Scope:
- Real-time predictive models requiring ML frameworks (scikit-learn, TensorFlow) -- scripts use rule-based scoring for portability
- Sentiment analysis of free-text survey responses (requires NLP libraries)
- Individual employee profiling or surveillance -- all analysis uses aggregated or anonymized data
- HRIS system administration, data pipeline engineering, or ETL development
- Legal interpretation of pay equity findings (requires Employment Law counsel)
- Organizational network analysis requiring email/calendar metadata
Known Limitations:
- Attrition risk scoring in scripts uses weighted heuristics, not trained ML models; accuracy depends on feature quality and weight calibration
- Pay equity analysis in the SKILL.md examples requires statsmodels (external dependency); scripts use standard-library approximations
- Survey analysis assumes Likert scale (1-5) responses; other formats require preprocessing
- Small population segments (< 30) produce unreliable statistical results; flag these in reporting
- Historical data biases (e.g., biased performance ratings) propagate into predictive models if not addressed
Integration Points
| System / Skill | Integration | Data Flow |
|---|---|---|
| HRIS (Workday, BambooHR, HiBob) | Employee master data, tenure, compensation, performance ratings | HRIS -> analytics data lake; analytics insights -> HRBP workforce plans |
| ATS (Greenhouse, Lever) | Hiring funnel data, source-of-hire, time-to-fill | ATS -> hiring analytics; quality-of-hire scoring feeds back to TA strategy |
| Survey Platform (Culture Amp, Qualtrics, Lattice) | Engagement survey responses, eNPS, pulse check data | Survey platform -> survey_analyzer.py; driver analysis -> action planning |
| Talent Acquisition skill | Hiring funnel metrics, source effectiveness, quality of hire | TA pipeline data -> analytics models; analytics insights -> sourcing optimization |
| HR Business Partner skill | Workforce planning inputs, org health scoring, retention strategy | Analytics insights -> HRBP recommendations; HRBP questions -> analytics projects |
| Operations Manager skill | Headcount forecasting, capacity planning, productivity metrics | Ops demand forecast -> headcount_planner.py; workforce metrics -> ops capacity models |
| Finance skill | Compensation budgets, cost modeling, headcount budget vs actual | Finance comp data -> pay equity analysis; headcount plan -> Finance budget model |
| Payroll (ADP, Gusto) | Compensation actuals, bonus payouts, overtime data | Payroll -> comp analysis; pay equity findings -> comp adjustment recommendations |
| BI Platform (Tableau, Looker, Power BI) | Dashboard hosting, self-service analytics, scheduled reporting | Analytics outputs -> BI dashboards; BI usage metrics -> adoption tracking |
#!/usr/bin/env python3
"""
Attrition Predictor - Score employees for attrition risk using rule-based heuristics.
Reads employee data CSV and computes a flight risk score (0-100) for each employee
based on weighted factors: tenure, compensation ratio, engagement, promotion recency,
manager tenure, and performance rating. No ML libraries required.
Usage:
python attrition_predictor.py --file employees.csv
python attrition_predictor.py --file employees.csv --threshold 70 --json
python attrition_predictor.py --file employees.csv --top 20
Input CSV columns:
employee_id - Unique employee identifier
department - Department name
tenure_months - Months of employment
salary_ratio_to_market - Ratio of salary to market median (e.g., 0.92 = 8% below)
performance_rating - Last performance rating (1-5 scale)
months_since_promotion - Months since last promotion
engagement_score - Last engagement survey score (1-5)
manager_tenure_months - Months the current manager has been in role
training_hours_ytd - Training hours year-to-date
level - Job level (optional)
location - Location (optional)
Output: Per-employee risk scores with risk factors and organizational summary.
"""
import argparse
import csv
import json
import os
import sys
from collections import defaultdict
# --- Risk factor weights and thresholds ---
WEIGHTS = {
"compensation": 25,
"promotion_stagnation": 20,
"engagement": 20,
"tenure_risk": 15,
"manager_instability": 10,
"development_gap": 10,
}
TENURE_RISK_BANDS = [
# (min_months, max_months, risk_score, label)
(0, 6, 30, "New hire - settling in"),
(6, 18, 60, "High-risk window - 6-18 months"),
(18, 36, 40, "Moderate - building career capital"),
(36, 60, 50, "Moderate - may seek growth externally"),
(60, 120, 35, "Established - lower base risk"),
(120, 999, 25, "Long-tenure - low mobility risk"),
]
def read_csv(path: str) -> list:
"""Read CSV file and return list of dicts."""
if not os.path.isfile(path):
print(f"Error: File not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
required = {"employee_id", "tenure_months", "salary_ratio_to_market", "engagement_score"}
if rows:
missing = required - set(rows[0].keys())
if missing:
print(f"Error: Missing required columns: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
return rows
def safe_float(val: str, default: float = 0.0) -> float:
"""Safely parse float."""
try:
return float(val)
except (ValueError, TypeError):
return default
def score_compensation(ratio: float) -> tuple:
"""Score compensation risk (0-100). Lower ratio = higher risk."""
if ratio >= 1.05:
return 10, "Above market"
elif ratio >= 0.95:
return 25, "At market"
elif ratio >= 0.90:
return 55, "Slightly below market"
elif ratio >= 0.85:
return 75, "Below market"
else:
return 95, "Significantly below market"
def score_promotion(months_since: float) -> tuple:
"""Score promotion stagnation risk (0-100)."""
if months_since <= 12:
return 10, "Recently promoted"
elif months_since <= 24:
return 30, "Within normal cycle"
elif months_since <= 36:
return 55, "Approaching stagnation"
elif months_since <= 48:
return 75, "Promotion overdue"
else:
return 90, "Significant stagnation"
def score_engagement(score: float) -> tuple:
"""Score engagement risk (0-100). Lower engagement = higher risk."""
if score >= 4.5:
return 10, "Highly engaged"
elif score >= 4.0:
return 25, "Engaged"
elif score >= 3.5:
return 45, "Neutral"
elif score >= 3.0:
return 70, "Disengaged"
else:
return 90, "Highly disengaged"
def score_tenure(months: float) -> tuple:
"""Score tenure-based risk (0-100)."""
for min_m, max_m, risk, label in TENURE_RISK_BANDS:
if min_m <= months < max_m:
return risk, label
return 30, "Unknown tenure band"
def score_manager_instability(months: float) -> tuple:
"""Score manager instability risk (0-100). New managers = higher risk."""
if months >= 24:
return 15, "Stable manager relationship"
elif months >= 12:
return 30, "Moderate manager tenure"
elif months >= 6:
return 55, "Recent manager change"
else:
return 80, "Very new manager"
def score_development(training_hours: float) -> tuple:
"""Score development investment risk (0-100). Low training = higher risk."""
if training_hours >= 40:
return 10, "Strong development investment"
elif training_hours >= 20:
return 30, "Adequate development"
elif training_hours >= 10:
return 55, "Below average development"
else:
return 80, "Minimal development investment"
def compute_risk_score(row: dict) -> dict:
"""Compute overall risk score for an employee."""
tenure = safe_float(row.get("tenure_months", 0))
comp_ratio = safe_float(row.get("salary_ratio_to_market", 1.0))
engagement = safe_float(row.get("engagement_score", 3.5))
months_promo = safe_float(row.get("months_since_promotion", 24))
mgr_tenure = safe_float(row.get("manager_tenure_months", 12))
training = safe_float(row.get("training_hours_ytd", 20))
perf = safe_float(row.get("performance_rating", 3.0))
comp_score, comp_label = score_compensation(comp_ratio)
promo_score, promo_label = score_promotion(months_promo)
eng_score, eng_label = score_engagement(engagement)
tenure_score, tenure_label = score_tenure(tenure)
mgr_score, mgr_label = score_manager_instability(mgr_tenure)
dev_score, dev_label = score_development(training)
# Weighted overall score
overall = (
comp_score * WEIGHTS["compensation"]
+ promo_score * WEIGHTS["promotion_stagnation"]
+ eng_score * WEIGHTS["engagement"]
+ tenure_score * WEIGHTS["tenure_risk"]
+ mgr_score * WEIGHTS["manager_instability"]
+ dev_score * WEIGHTS["development_gap"]
) / 100
# Adjust for high performers (higher risk when other factors are negative)
if perf >= 4.0 and overall > 50:
overall = min(100, overall * 1.1) # High performers at risk are extra costly
overall = round(min(100, max(0, overall)), 1)
# Risk level
if overall >= 75:
risk_level = "CRITICAL"
elif overall >= 50:
risk_level = "HIGH"
elif overall >= 30:
risk_level = "MEDIUM"
else:
risk_level = "LOW"
# Top risk factors (sorted by contribution)
factors = [
{"factor": "Compensation", "score": comp_score, "weight": WEIGHTS["compensation"], "detail": f"{comp_label} (ratio: {comp_ratio:.2f})"},
{"factor": "Promotion", "score": promo_score, "weight": WEIGHTS["promotion_stagnation"], "detail": f"{promo_label} ({months_promo:.0f} months)"},
{"factor": "Engagement", "score": eng_score, "weight": WEIGHTS["engagement"], "detail": f"{eng_label} (score: {engagement:.1f})"},
{"factor": "Tenure", "score": tenure_score, "weight": WEIGHTS["tenure_risk"], "detail": f"{tenure_label} ({tenure:.0f} months)"},
{"factor": "Manager", "score": mgr_score, "weight": WEIGHTS["manager_instability"], "detail": f"{mgr_label} ({mgr_tenure:.0f} months)"},
{"factor": "Development", "score": dev_score, "weight": WEIGHTS["development_gap"], "detail": f"{dev_label} ({training:.0f} hrs YTD)"},
]
factors.sort(key=lambda x: x["score"] * x["weight"], reverse=True)
return {
"employee_id": row["employee_id"],
"department": row.get("department", ""),
"level": row.get("level", ""),
"risk_score": overall,
"risk_level": risk_level,
"performance_rating": perf,
"top_risk_factors": factors[:3],
"all_factors": factors,
}
def compute_summary(results: list) -> dict:
"""Compute organizational summary statistics."""
total = len(results)
if total == 0:
return {}
critical = sum(1 for r in results if r["risk_level"] == "CRITICAL")
high = sum(1 for r in results if r["risk_level"] == "HIGH")
medium = sum(1 for r in results if r["risk_level"] == "MEDIUM")
low = sum(1 for r in results if r["risk_level"] == "LOW")
scores = [r["risk_score"] for r in results]
avg_score = sum(scores) / len(scores)
# High performers at risk
high_perf_at_risk = [r for r in results if r["performance_rating"] >= 4.0 and r["risk_level"] in ("CRITICAL", "HIGH")]
# Department breakdown
dept_risk = defaultdict(list)
for r in results:
dept = r.get("department", "Unknown") or "Unknown"
dept_risk[dept].append(r["risk_score"])
dept_summary = []
for dept, scores_list in sorted(dept_risk.items()):
dept_avg = sum(scores_list) / len(scores_list)
dept_critical = sum(1 for s in scores_list if s >= 75)
dept_summary.append({
"department": dept,
"employee_count": len(scores_list),
"avg_risk_score": round(dept_avg, 1),
"critical_count": dept_critical,
})
dept_summary.sort(key=lambda x: x["avg_risk_score"], reverse=True)
return {
"total_employees": total,
"risk_distribution": {
"critical": critical,
"high": high,
"medium": medium,
"low": low,
},
"avg_risk_score": round(avg_score, 1),
"high_performers_at_risk": len(high_perf_at_risk),
"department_summary": dept_summary,
}
def format_human(results: list, summary: dict, threshold: float) -> str:
"""Format results for human-readable output."""
lines = []
lines.append("=" * 70)
lines.append("ATTRITION RISK ASSESSMENT REPORT")
lines.append("=" * 70)
lines.append("")
dist = summary["risk_distribution"]
lines.append(f" Total Employees Scored: {summary['total_employees']}")
lines.append(f" Average Risk Score: {summary['avg_risk_score']}/100")
lines.append(f" High Performers at Risk: {summary['high_performers_at_risk']}")
lines.append("")
lines.append(f" Risk Distribution:")
lines.append(f" CRITICAL (75+): {dist['critical']:>4} ({dist['critical']/summary['total_employees']*100:.1f}%)")
lines.append(f" HIGH (50-74): {dist['high']:>4} ({dist['high']/summary['total_employees']*100:.1f}%)")
lines.append(f" MEDIUM (30-49): {dist['medium']:>4} ({dist['medium']/summary['total_employees']*100:.1f}%)")
lines.append(f" LOW (0-29): {dist['low']:>4} ({dist['low']/summary['total_employees']*100:.1f}%)")
# Department summary
if summary["department_summary"]:
lines.append("")
lines.append("-" * 70)
lines.append("DEPARTMENT RISK SUMMARY")
lines.append("-" * 70)
lines.append(f" {'Department':<25} {'Employees':>10} {'Avg Risk':>10} {'Critical':>10}")
lines.append(f" {'-'*25} {'-'*10} {'-'*10} {'-'*10}")
for dept in summary["department_summary"]:
lines.append(f" {dept['department']:<25} {dept['employee_count']:>10} {dept['avg_risk_score']:>10.1f} {dept['critical_count']:>10}")
# Individual results above threshold
flagged = [r for r in results if r["risk_score"] >= threshold]
flagged.sort(key=lambda x: x["risk_score"], reverse=True)
if flagged:
lines.append("")
lines.append("-" * 70)
lines.append(f"EMPLOYEES ABOVE RISK THRESHOLD ({threshold})")
lines.append("-" * 70)
for r in flagged[:30]:
perf_tag = " [HIGH PERFORMER]" if r["performance_rating"] >= 4.0 else ""
lines.append(f"\n {r['employee_id']} | {r['department']} | Risk: {r['risk_score']}/100 ({r['risk_level']}){perf_tag}")
for f in r["top_risk_factors"]:
lines.append(f" - {f['factor']}: {f['detail']}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Score employees for attrition risk using rule-based heuristics."
)
parser.add_argument("--file", required=True, help="Path to employee data CSV")
parser.add_argument("--threshold", type=float, default=50, help="Risk score threshold for flagging (default: 50)")
parser.add_argument("--top", type=int, default=None, help="Show only top N highest-risk employees")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
args = parser.parse_args()
rows = read_csv(args.file)
if not rows:
print("Error: No data found in CSV file.", file=sys.stderr)
sys.exit(1)
results = [compute_risk_score(row) for row in rows]
results.sort(key=lambda x: x["risk_score"], reverse=True)
if args.top:
results = results[:args.top]
summary = compute_summary(results)
if args.json:
output = {
"summary": summary,
"employees": results,
}
print(json.dumps(output, indent=2))
else:
print(format_human(results, summary, args.threshold))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Headcount Planner - Workforce planning calculations and scenario modeling.
Reads current workforce data and computes future headcount needs based on
growth targets, attrition assumptions, and hiring capacity. Supports
multi-quarter forecasting and department-level breakdowns.
Usage:
python headcount_planner.py --file workforce.csv --growth 0.15 --attrition 0.12
python headcount_planner.py --file workforce.csv --growth 0.15 --attrition 0.12 --quarters 4 --json
python headcount_planner.py --file workforce.csv --growth 0.20 --attrition 0.10 --hiring-capacity 15
Input CSV columns:
department - Department name
current_headcount - Current headcount in department
open_roles - Number of open/approved roles
attrition_rate - Department-specific attrition rate (optional, overrides --attrition)
avg_cost_per_hire - Average cost per hire for department (optional)
avg_salary - Average salary for department (optional)
growth_rate - Department-specific growth rate (optional, overrides --growth)
Output: Quarter-by-quarter headcount plan with hiring needs, costs, and gap analysis.
"""
import argparse
import csv
import json
import math
import os
import sys
def read_csv(path: str) -> list:
"""Read CSV file and return list of dicts."""
if not os.path.isfile(path):
print(f"Error: File not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
required = {"department", "current_headcount"}
if rows:
missing = required - set(rows[0].keys())
if missing:
print(f"Error: Missing required columns: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
return rows
def safe_float(val: str, default: float = 0.0) -> float:
"""Safely parse float."""
try:
return float(val)
except (ValueError, TypeError):
return default
def safe_int(val: str, default: int = 0) -> int:
"""Safely parse int."""
try:
return int(float(val))
except (ValueError, TypeError):
return default
def forecast_department(dept: dict, growth_rate: float, attrition_rate: float,
quarters: int, hiring_capacity: int = None) -> dict:
"""Forecast headcount for a single department over N quarters."""
name = dept["department"]
current_hc = safe_int(dept.get("current_headcount", 0))
open_roles = safe_int(dept.get("open_roles", 0))
dept_attrition = safe_float(dept.get("attrition_rate"), attrition_rate)
dept_growth = safe_float(dept.get("growth_rate"), growth_rate)
avg_cost = safe_float(dept.get("avg_cost_per_hire", 4500))
avg_salary = safe_float(dept.get("avg_salary", 100000))
# Quarterly rates
quarterly_attrition = dept_attrition / 4
quarterly_growth = dept_growth / 4
# Target headcount at end of planning horizon
target_hc = math.ceil(current_hc * (1 + dept_growth))
quarterly_plan = []
running_hc = current_hc
total_hires_needed = 0
total_cost = 0
total_salary_cost = 0
for q in range(1, quarters + 1):
# Expected attrition this quarter
expected_attrition = math.ceil(running_hc * quarterly_attrition)
# Growth hires this quarter
growth_target = math.ceil(current_hc * quarterly_growth)
# Total hires needed = backfill + growth + remaining open roles
backfill = expected_attrition
growth_hires = growth_target
open_fill = open_roles if q == 1 else 0 # Fill open roles in Q1
total_q_hires = backfill + growth_hires + open_fill
# Apply hiring capacity constraint
if hiring_capacity is not None:
total_q_hires = min(total_q_hires, hiring_capacity)
# Net change
net_change = total_q_hires - expected_attrition
# End of quarter headcount
end_hc = running_hc + net_change
# Costs
hiring_cost = total_q_hires * avg_cost
incremental_salary = total_q_hires * avg_salary * 0.25 # Partial quarter
quarterly_plan.append({
"quarter": f"Q{q}",
"start_headcount": running_hc,
"expected_attrition": expected_attrition,
"backfill_hires": backfill,
"growth_hires": growth_hires,
"open_role_fills": open_fill,
"total_hires": total_q_hires,
"net_change": net_change,
"end_headcount": end_hc,
"hiring_cost": round(hiring_cost),
"incremental_salary_cost": round(incremental_salary),
})
total_hires_needed += total_q_hires
total_cost += hiring_cost
total_salary_cost += incremental_salary
running_hc = end_hc
gap = target_hc - running_hc
return {
"department": name,
"current_headcount": current_hc,
"open_roles": open_roles,
"target_headcount": target_hc,
"final_headcount": running_hc,
"gap_to_target": gap,
"growth_rate": dept_growth,
"attrition_rate": dept_attrition,
"total_hires_needed": total_hires_needed,
"total_hiring_cost": round(total_cost),
"total_incremental_salary": round(total_salary_cost),
"quarterly_plan": quarterly_plan,
}
def compute_org_summary(forecasts: list) -> dict:
"""Compute organization-level summary."""
total_current = sum(f["current_headcount"] for f in forecasts)
total_target = sum(f["target_headcount"] for f in forecasts)
total_final = sum(f["final_headcount"] for f in forecasts)
total_hires = sum(f["total_hires_needed"] for f in forecasts)
total_hiring_cost = sum(f["total_hiring_cost"] for f in forecasts)
total_salary_cost = sum(f["total_incremental_salary"] for f in forecasts)
total_open = sum(f["open_roles"] for f in forecasts)
# Aggregate quarterly
quarters_count = len(forecasts[0]["quarterly_plan"]) if forecasts else 0
quarterly_totals = []
for q in range(quarters_count):
q_data = {
"quarter": f"Q{q+1}",
"total_hires": sum(f["quarterly_plan"][q]["total_hires"] for f in forecasts),
"total_attrition": sum(f["quarterly_plan"][q]["expected_attrition"] for f in forecasts),
"net_change": sum(f["quarterly_plan"][q]["net_change"] for f in forecasts),
"hiring_cost": sum(f["quarterly_plan"][q]["hiring_cost"] for f in forecasts),
}
quarterly_totals.append(q_data)
return {
"current_total_headcount": total_current,
"target_total_headcount": total_target,
"projected_final_headcount": total_final,
"total_open_roles": total_open,
"total_hires_needed": total_hires,
"total_hiring_cost": total_hiring_cost,
"total_incremental_salary_cost": total_salary_cost,
"total_investment": total_hiring_cost + total_salary_cost,
"net_growth": total_final - total_current,
"net_growth_pct": round((total_final - total_current) / total_current * 100, 1) if total_current > 0 else 0,
"quarterly_totals": quarterly_totals,
}
def format_human(forecasts: list, summary: dict, quarters: int) -> str:
"""Format results for human-readable output."""
lines = []
lines.append("=" * 75)
lines.append("WORKFORCE HEADCOUNT PLAN")
lines.append("=" * 75)
lines.append("")
lines.append(f" Planning Horizon: {quarters} quarters")
lines.append(f" Current Headcount: {summary['current_total_headcount']}")
lines.append(f" Target Headcount: {summary['target_total_headcount']}")
lines.append(f" Projected Final: {summary['projected_final_headcount']}")
lines.append(f" Net Growth: {summary['net_growth']} ({summary['net_growth_pct']}%)")
lines.append(f" Open Roles to Fill: {summary['total_open_roles']}")
lines.append(f" Total Hires Needed: {summary['total_hires_needed']}")
lines.append(f" Total Hiring Cost: ${summary['total_hiring_cost']:,.0f}")
lines.append(f" Incremental Salary Cost: ${summary['total_incremental_salary_cost']:,.0f}")
lines.append(f" Total Investment: ${summary['total_investment']:,.0f}")
# Quarterly overview
lines.append("")
lines.append("-" * 75)
lines.append("QUARTERLY OVERVIEW (ALL DEPARTMENTS)")
lines.append("-" * 75)
lines.append(f" {'Quarter':<10} {'Hires':>8} {'Attrition':>10} {'Net':>8} {'Hiring Cost':>14}")
lines.append(f" {'-'*10} {'-'*8} {'-'*10} {'-'*8} {'-'*14}")
for qt in summary["quarterly_totals"]:
lines.append(f" {qt['quarter']:<10} {qt['total_hires']:>8} {qt['total_attrition']:>10} {qt['net_change']:>+8} ${qt['hiring_cost']:>12,.0f}")
# Department detail
lines.append("")
lines.append("-" * 75)
lines.append("DEPARTMENT BREAKDOWN")
lines.append("-" * 75)
lines.append(f" {'Department':<20} {'Current':>8} {'Target':>8} {'Final':>8} {'Hires':>8} {'Gap':>6} {'Cost':>12}")
lines.append(f" {'-'*20} {'-'*8} {'-'*8} {'-'*8} {'-'*8} {'-'*6} {'-'*12}")
for f in forecasts:
lines.append(
f" {f['department']:<20} {f['current_headcount']:>8} {f['target_headcount']:>8} "
f"{f['final_headcount']:>8} {f['total_hires_needed']:>8} {f['gap_to_target']:>+6} "
f"${f['total_hiring_cost']:>10,.0f}"
)
# Detailed quarterly plans per department
for f in forecasts:
lines.append("")
lines.append(f" --- {f['department']} (Attrition: {f['attrition_rate']*100:.0f}%, Growth: {f['growth_rate']*100:.0f}%) ---")
lines.append(f" {'Qtr':<6} {'Start':>7} {'Attrit':>7} {'Back':>6} {'Grow':>6} {'Open':>6} {'Total':>6} {'End':>7}")
for qp in f["quarterly_plan"]:
lines.append(
f" {qp['quarter']:<6} {qp['start_headcount']:>7} {qp['expected_attrition']:>7} "
f"{qp['backfill_hires']:>6} {qp['growth_hires']:>6} {qp['open_role_fills']:>6} "
f"{qp['total_hires']:>6} {qp['end_headcount']:>7}"
)
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Workforce headcount planning and scenario modeling."
)
parser.add_argument("--file", required=True, help="Path to workforce data CSV")
parser.add_argument("--growth", type=float, required=True, help="Annual growth rate (e.g., 0.15 for 15%)")
parser.add_argument("--attrition", type=float, required=True, help="Annual attrition rate (e.g., 0.12 for 12%)")
parser.add_argument("--quarters", type=int, default=4, help="Number of quarters to forecast (default: 4)")
parser.add_argument("--hiring-capacity", type=int, default=None, help="Max hires per quarter per department")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
args = parser.parse_args()
rows = read_csv(args.file)
if not rows:
print("Error: No data found in CSV file.", file=sys.stderr)
sys.exit(1)
forecasts = []
for row in rows:
forecast = forecast_department(row, args.growth, args.attrition, args.quarters, args.hiring_capacity)
forecasts.append(forecast)
summary = compute_org_summary(forecasts)
if args.json:
output = {
"summary": summary,
"departments": forecasts,
}
print(json.dumps(output, indent=2))
else:
print(format_human(forecasts, summary, args.quarters))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Survey Analyzer - Analyze employee engagement survey results.
Reads survey response CSV data and computes category scores, response rates,
driver analysis (impact on overall engagement), period-over-period comparison,
and segment breakdowns with priority matrix classification.
Usage:
python survey_analyzer.py --file survey_results.csv
python survey_analyzer.py --file survey_results.csv --prior prior_survey.csv --json
python survey_analyzer.py --file survey_results.csv --segment department
Input CSV columns:
respondent_id - Unique anonymous respondent ID
department - Department name (optional, for segmentation)
level - Job level (optional, for segmentation)
tenure_band - Tenure grouping (optional, for segmentation)
location - Office location (optional, for segmentation)
category - Survey question category (e.g., Manager, Growth, Culture, Compensation, Workload)
question - Survey question text
score - Likert score (1-5)
Output: Category scores, driver analysis, eNPS, segment breakdowns, and recommendations.
"""
import argparse
import csv
import json
import math
import os
import sys
from collections import defaultdict
CATEGORY_BENCHMARKS = {
"manager": 3.8,
"growth": 3.5,
"culture": 3.7,
"compensation": 3.3,
"workload": 3.4,
"belonging": 3.6,
"communication": 3.5,
"recognition": 3.4,
"autonomy": 3.6,
"mission": 3.8,
}
MIN_SEGMENT_SIZE = 5 # Anonymity threshold
def read_csv(path: str) -> list:
"""Read CSV file and return list of dicts."""
if not os.path.isfile(path):
print(f"Error: File not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
required = {"respondent_id", "category", "score"}
if rows:
missing = required - set(rows[0].keys())
if missing:
print(f"Error: Missing required columns: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
return rows
def parse_score(val: str) -> float:
"""Parse score value."""
try:
s = float(val)
if 1 <= s <= 5:
return s
except (ValueError, TypeError):
pass
return None
def compute_response_rate(rows: list, total_employees: int = None) -> dict:
"""Compute response rate metrics."""
respondents = set()
for row in rows:
respondents.add(row["respondent_id"])
count = len(respondents)
rate = round(count / total_employees * 100, 1) if total_employees else None
return {
"respondents": count,
"total_employees": total_employees,
"response_rate_pct": rate,
"meets_threshold": rate >= 80 if rate else None,
}
def compute_category_scores(rows: list) -> list:
"""Compute average score per category."""
cat_scores = defaultdict(list)
for row in rows:
score = parse_score(row["score"])
if score is not None:
cat = row["category"].strip().lower()
cat_scores[cat].append(score)
results = []
for cat, scores in sorted(cat_scores.items()):
avg = sum(scores) / len(scores)
benchmark = CATEGORY_BENCHMARKS.get(cat, 3.5)
favorable = sum(1 for s in scores if s >= 4) / len(scores) * 100
results.append({
"category": cat.title(),
"avg_score": round(avg, 2),
"favorable_pct": round(favorable, 1),
"response_count": len(scores),
"benchmark": benchmark,
"vs_benchmark": round(avg - benchmark, 2),
})
results.sort(key=lambda x: x["avg_score"])
return results
def compute_overall_engagement(rows: list) -> dict:
"""Compute overall engagement score."""
scores = []
for row in rows:
score = parse_score(row["score"])
if score is not None:
scores.append(score)
if not scores:
return {"avg_score": 0, "favorable_pct": 0, "response_count": 0}
avg = sum(scores) / len(scores)
favorable = sum(1 for s in scores if s >= 4) / len(scores) * 100
# eNPS approximation: promoters (5) - detractors (1-3)
promoters = sum(1 for s in scores if s == 5) / len(scores) * 100
detractors = sum(1 for s in scores if s <= 3) / len(scores) * 100
enps = round(promoters - detractors)
return {
"avg_score": round(avg, 2),
"favorable_pct": round(favorable, 1),
"enps": enps,
"response_count": len(scores),
}
def compute_driver_analysis(rows: list) -> list:
"""
Simple driver analysis: compute correlation between each category
and overall engagement (respondent-level average).
Uses Pearson correlation approximation with standard library.
"""
# Build respondent-level data
respondent_cats = defaultdict(lambda: defaultdict(list))
respondent_overall = defaultdict(list)
for row in rows:
score = parse_score(row["score"])
if score is not None:
rid = row["respondent_id"]
cat = row["category"].strip().lower()
respondent_cats[rid][cat].append(score)
respondent_overall[rid].append(score)
# Average per respondent per category and overall
respondent_cat_avg = {}
respondent_overall_avg = {}
for rid in respondent_overall:
respondent_overall_avg[rid] = sum(respondent_overall[rid]) / len(respondent_overall[rid])
respondent_cat_avg[rid] = {}
for cat, scores in respondent_cats[rid].items():
respondent_cat_avg[rid][cat] = sum(scores) / len(scores)
# Compute correlation per category
all_cats = set()
for rid in respondent_cat_avg:
all_cats.update(respondent_cat_avg[rid].keys())
drivers = []
for cat in all_cats:
x_vals = []
y_vals = []
for rid in respondent_cat_avg:
if cat in respondent_cat_avg[rid]:
x_vals.append(respondent_cat_avg[rid][cat])
y_vals.append(respondent_overall_avg[rid])
if len(x_vals) < 5:
continue
# Pearson correlation
n = len(x_vals)
mean_x = sum(x_vals) / n
mean_y = sum(y_vals) / n
cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(x_vals, y_vals)) / n
std_x = math.sqrt(sum((x - mean_x) ** 2 for x in x_vals) / n)
std_y = math.sqrt(sum((y - mean_y) ** 2 for y in y_vals) / n)
if std_x > 0 and std_y > 0:
r = cov / (std_x * std_y)
else:
r = 0
avg_score = sum(x_vals) / len(x_vals)
drivers.append({
"category": cat.title(),
"impact": round(r, 3),
"avg_score": round(avg_score, 2),
})
drivers.sort(key=lambda x: x["impact"], reverse=True)
return drivers
def classify_priorities(drivers: list) -> list:
"""
Classify categories into priority matrix quadrants:
High Impact + Low Score = Priority Action
High Impact + High Score = Maintain
Low Impact + Low Score = Monitor
Low Impact + High Score = Celebrate
"""
if not drivers:
return []
median_impact = sorted([d["impact"] for d in drivers])[len(drivers) // 2]
median_score = sorted([d["avg_score"] for d in drivers])[len(drivers) // 2]
for d in drivers:
high_impact = d["impact"] >= median_impact
high_score = d["avg_score"] >= median_score
if high_impact and not high_score:
d["quadrant"] = "PRIORITY_ACTION"
elif high_impact and high_score:
d["quadrant"] = "MAINTAIN"
elif not high_impact and not high_score:
d["quadrant"] = "MONITOR"
else:
d["quadrant"] = "CELEBRATE"
return drivers
def compute_segment_breakdown(rows: list, segment_field: str) -> list:
"""Compute scores by segment (department, level, etc.)."""
seg_scores = defaultdict(list)
for row in rows:
score = parse_score(row["score"])
seg_val = row.get(segment_field, "").strip()
if score is not None and seg_val:
seg_scores[seg_val].append(score)
results = []
for seg, scores in sorted(seg_scores.items()):
if len(set(row["respondent_id"] for row in rows if row.get(segment_field, "").strip() == seg)) < MIN_SEGMENT_SIZE:
results.append({
"segment": seg,
"suppressed": True,
"reason": f"Fewer than {MIN_SEGMENT_SIZE} respondents (anonymity protection)",
})
continue
avg = sum(scores) / len(scores)
favorable = sum(1 for s in scores if s >= 4) / len(scores) * 100
results.append({
"segment": seg,
"avg_score": round(avg, 2),
"favorable_pct": round(favorable, 1),
"response_count": len(scores),
"suppressed": False,
})
results.sort(key=lambda x: x.get("avg_score", 0))
return results
def compare_periods(current: list, prior: list) -> list:
"""Compare current period scores to prior period."""
prior_cats = {}
for row in prior:
score = parse_score(row["score"])
if score is not None:
cat = row["category"].strip().lower()
if cat not in prior_cats:
prior_cats[cat] = []
prior_cats[cat].append(score)
prior_avgs = {cat: sum(s) / len(s) for cat, s in prior_cats.items()}
current_cats = compute_category_scores(current)
for cat_data in current_cats:
cat_key = cat_data["category"].lower()
if cat_key in prior_avgs:
cat_data["prior_score"] = round(prior_avgs[cat_key], 2)
cat_data["change"] = round(cat_data["avg_score"] - prior_avgs[cat_key], 2)
else:
cat_data["prior_score"] = None
cat_data["change"] = None
return current_cats
def build_recommendations(category_scores: list, drivers: list) -> list:
"""Generate recommendations from analysis."""
recs = []
# Find priority action items
priority_items = [d for d in drivers if d.get("quadrant") == "PRIORITY_ACTION"]
for item in priority_items[:3]:
recs.append(
f"[PRIORITY] {item['category']} has high impact on engagement (r={item['impact']}) "
f"but scores below median ({item['avg_score']}/5.0). Investigate root causes and develop a targeted action plan."
)
# Low-scoring categories
for cat in category_scores[:2]:
if cat["vs_benchmark"] < -0.3:
recs.append(
f"{cat['category']} scores {abs(cat['vs_benchmark']):.2f} below benchmark "
f"({cat['avg_score']} vs {cat['benchmark']}). Review with department leaders and identify specific pain points."
)
if not recs:
recs.append("All categories are at or above benchmark. Focus on maintaining momentum and addressing any segment-specific gaps.")
return recs
def format_human(overall: dict, categories: list, drivers: list, segments: list,
recommendations: list, response_rate: dict) -> str:
"""Format results for human-readable output."""
lines = []
lines.append("=" * 65)
lines.append("ENGAGEMENT SURVEY ANALYSIS REPORT")
lines.append("=" * 65)
lines.append("")
lines.append(f" Overall Engagement Score: {overall['avg_score']} / 5.0")
lines.append(f" Favorable Response Rate: {overall['favorable_pct']}%")
lines.append(f" eNPS: {overall['enps']}")
lines.append(f" Total Responses: {overall['response_count']}")
if response_rate.get("response_rate_pct"):
lines.append(f" Survey Response Rate: {response_rate['response_rate_pct']}%")
lines.append("")
lines.append("-" * 65)
lines.append("CATEGORY SCORES")
lines.append("-" * 65)
lines.append(f" {'Category':<20} {'Score':>6} {'Fav%':>6} {'Bench':>6} {'Delta':>7}")
lines.append(f" {'-'*20} {'-'*6} {'-'*6} {'-'*6} {'-'*7}")
for cat in categories:
delta = f"{cat['vs_benchmark']:+.2f}" if cat['vs_benchmark'] else "--"
lines.append(f" {cat['category']:<20} {cat['avg_score']:>6.2f} {cat['favorable_pct']:>5.1f}% {cat['benchmark']:>6.2f} {delta:>7}")
if drivers:
lines.append("")
lines.append("-" * 65)
lines.append("DRIVER ANALYSIS (Impact on Overall Engagement)")
lines.append("-" * 65)
lines.append(f" {'Category':<20} {'Impact':>8} {'Score':>6} {'Quadrant':<20}")
lines.append(f" {'-'*20} {'-'*8} {'-'*6} {'-'*20}")
for d in drivers:
quad = d.get("quadrant", "--").replace("_", " ").title()
lines.append(f" {d['category']:<20} {d['impact']:>8.3f} {d['avg_score']:>6.2f} {quad:<20}")
if segments:
lines.append("")
lines.append("-" * 65)
lines.append("SEGMENT BREAKDOWN")
lines.append("-" * 65)
for seg in segments:
if seg.get("suppressed"):
lines.append(f" {seg['segment']:<25} [SUPPRESSED - {seg['reason']}]")
else:
lines.append(f" {seg['segment']:<25} Score: {seg['avg_score']:.2f} Favorable: {seg['favorable_pct']:.1f}% (n={seg['response_count']})")
lines.append("")
lines.append("-" * 65)
lines.append("RECOMMENDATIONS")
lines.append("-" * 65)
for i, rec in enumerate(recommendations, 1):
lines.append(f" {i}. {rec}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Analyze employee engagement survey results."
)
parser.add_argument("--file", required=True, help="Path to survey results CSV")
parser.add_argument("--prior", default=None, help="Path to prior period survey CSV for comparison")
parser.add_argument("--segment", default="department", help="Segment field for breakdown (default: department)")
parser.add_argument("--total-employees", type=int, default=None, help="Total employee count for response rate calculation")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
args = parser.parse_args()
rows = read_csv(args.file)
if not rows:
print("Error: No data found in CSV file.", file=sys.stderr)
sys.exit(1)
prior_rows = read_csv(args.prior) if args.prior else None
overall = compute_overall_engagement(rows)
response_rate = compute_response_rate(rows, args.total_employees)
if prior_rows:
categories = compare_periods(rows, prior_rows)
else:
categories = compute_category_scores(rows)
drivers = compute_driver_analysis(rows)
drivers = classify_priorities(drivers)
segments = compute_segment_breakdown(rows, args.segment)
recommendations = build_recommendations(categories, drivers)
if args.json:
output = {
"overall_engagement": overall,
"response_rate": response_rate,
"category_scores": categories,
"driver_analysis": drivers,
"segment_breakdown": segments,
"recommendations": recommendations,
}
print(json.dumps(output, indent=2))
else:
print(format_human(overall, categories, drivers, segments, recommendations, response_rate))
if __name__ == "__main__":
main()