LS 2.0 Facility Survey — Analytics & Visualisation¶

Thirty decision questions the LS 2.0 questionnaire was designed to answer, each worked end to end: the SQL run against the SQLite warehouse built by the ETL (outputs/ls2_survey.db), the result table, a Plotly figure (interactive when the cell is run), and the answer in plain language.

Re-run all cells after python run_pipeline.py to refresh every answer.

The dataset is generated from the structure of the questionnaire (184 facilities × 6 bi-weekly rounds). The pipeline, SQL and charts run unchanged on a real export with the same sheet layout.

Contents

Section Questions
Facility access & cold chain Q01–Q04
Service delivery Q05–Q07
Human resources for health Q08–Q14
Supply chain Q15–Q21
Vaccines Q22–Q24
Composite readiness & predictors Q25–Q30
Predictive analytics stock-out risk, at-risk facilities, attendance drivers, segments
Dashboard in-notebook filterable dashboard + the hosted editions
import sqlite3, sys
from pathlib import Path

import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio

ROOT = Path.cwd().resolve()
while not (ROOT / "src").exists():
    ROOT = ROOT.parent
sys.path.insert(0, str(ROOT / "src"))
import config as C
from analysis import theme as T                       # shared Plotly template (registered as "ls2")
from analysis.questions import Result, q, pct, hbar   # helpers used by every question below

# Interactive figures when the notebook is run live; a static PNG is what gets saved so every viewer (GitHub included) shows the charts
pio.renderers.default = "notebook_connected+png"
pio.defaults.default_width, pio.defaults.default_height, pio.defaults.default_scale = 900, 500, 1.5
pio.templates.default = "ls2"
pd.set_option("display.max_columns", 30); pd.set_option("display.width", 160); pd.set_option("display.float_format", "{:.3f}".format)

con = sqlite3.connect(ROOT / "outputs" / "ls2_survey.db")
print(pd.read_sql_query("SELECT table_name, row_count FROM etl_log ORDER BY row_count DESC", con).to_string(index=False))
              table_name  row_count
         commodity_stock      25392
       staffing_by_cadre       9936
        service_sessions       8832
           vaccine_stock       7854
    cold_chain_equipment       7728
          visit_services       7470
visit_vaccines_dispensed       5070
         absence_reasons       4262
         visit_cce_types       3756
 visit_requisition_types       1502
   visit_vaccine_sources       1367
         facility_visits       1104
 visit_commodity_sources       1097
               trainings        618
     visit_salary_issues        552
              facilities        184

The warehouse¶

facility_visits is the fact table (one row per facility per round). Long tables hang off it by visit_id, and the views v_visits, v_commodity, v_staffing, v_absence, v_vaccine and v_sessions pre-join facility context.

views = pd.read_sql_query("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name", con)
print(", ".join(views[views.type == "view"].name), "\n")
pd.read_sql_query("SELECT * FROM v_visits LIMIT 3", con).T.head(40)
v_absence, v_commodity, v_sessions, v_staffing, v_vaccine, v_visits 

0 1 2
visit_id KD-001-R1 KD-001-R2 KD-001-R3
facility_id KD-001 KD-001 KD-001
round_number 1 2 3
round_name Baseline Bi-weekly 1 Bi-weekly 2
visit_date 2026-03-02 2026-03-17 2026-03-31
data_champion Bala Danladi Bala Danladi Bala Danladi
respondent_name Zainab Ishaya Zainab Ishaya Zainab Ishaya
respondent_cadre Pharmacy Technician Pharmacy Technician Pharmacy Technician
respondent_position OIC OIC OIC
hours_of_operation 24 hours 24 hours 24 hours
days_open_per_week 7 7 7
facility_open_on_arrival No Yes Yes
arrival_time 10:13 08:17 09:00
facility_opened_time 11:41 NaN NaN
wait_minutes 41 0 0
reason_closed_on_arrival Staff at LGA meeting or training NaN NaN
can_refer_emergencies Yes Yes Yes
emergency_transport_available_2wks No Yes Yes
total_staff 28 26 26
permanent_staff 18 17 17
adhoc_a_staff 3 3 3
adhoc_b_staff 1 1 1
volunteer_staff 5 4 4
total_health_workers 23 23 23
male_health_workers 16 10 10
female_health_workers 7 13 13
permanent_health_workers 15 15 15
has_cold_chain_equipment Yes Yes Yes
cce_types_available Temperature Monitoring Devices;Cold Boxes (6L)... Temperature Monitoring Devices;Cold Boxes (6L)... Temperature Monitoring Devices;Cold Boxes (6L)...
vaccine_fridge_functional No No No
cold_chain_interruption_since_last_visit Yes No Yes
cold_chain_interruption_reason Power failure / no electricity NaN Power failure / no electricity
services_provided Immunization;Nutrition;Antenatal Care;Postnata... Immunization;Nutrition;Antenatal Care;Postnata... Immunization;Nutrition;Antenatal Care;Postnata...
staff_trained_past_2_years Yes Yes Yes
has_dedicated_service_days Yes Yes Yes
immunization_sessions_all_conducted No Yes No
maintains_attendance_register Yes Yes Yes
attendance_register_type Paper-based Paper-based Paper-based
attendance_register_updated_daily Yes Yes No
no_register_reason None None None
results = []

Facility access¶

Q01. How often are facilities actually open when a data champion arrives, and where is access weakest?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q01(con):
    sql = """
    SELECT lga, COUNT(*) AS visits, AVG(facility_open_on_arrival_flag) AS open_rate,
           AVG(CASE WHEN facility_open_on_arrival_flag=0 THEN wait_minutes END) AS avg_wait_when_closed
    FROM v_visits GROUP BY lga ORDER BY open_rate"""
    df = q(con, sql)
    overall = q(con, "SELECT AVG(facility_open_on_arrival_flag) r, AVG(CASE WHEN facility_open_on_arrival_flag=0 THEN wait_minutes END) w FROM v_visits").iloc[0]
    worst, best = df.iloc[0], df.iloc[-1]
    fig = hbar(df, "open_rate", "lga", "Share of visits where the facility was open on arrival, by LGA", xlabel="Open on arrival")
    fig.add_vline(x=overall.r, line_dash="dot", line_color=T.TEXT_SECONDARY, annotation_text=f"State avg {pct(overall.r)}", annotation_position="top")
    ans = (f"Facilities were open on arrival in {pct(overall.r)} of {int(df.visits.sum())} visits. Where closed, data champions "
           f"waited {overall.w:.0f} minutes on average. {worst.lga} is the weakest LGA ({pct(worst.open_rate)}) and "
           f"{best.lga} the strongest ({pct(best.open_rate)}).")
    return Result("Q01", "Facility access", "How often are facilities actually open when a data champion arrives, and where is access weakest?",
                  sql, df, ans, fig, key_numbers={"open_on_arrival_rate": float(overall.r), "avg_wait_minutes": float(overall.w)})
r = q01(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
lga visits open_rate avg_wait_when_closed
0 Kauru 48 0.688 45.333
1 Igabi 48 0.708 52.786
2 Chikun 48 0.729 52.615
3 Jema'a 48 0.729 55.846
4 Kachia 48 0.729 43.231
5 Zangon Kataf 48 0.729 42.000
6 Sabon Gari 48 0.750 49.250
7 Birnin Gwari 48 0.771 64.000
8 Soba 48 0.771 40.545
9 Giwa 48 0.792 53.400
10 Kaduna North 48 0.792 25.100
11 Kagarko 48 0.812 55.333
12 Kajuru 48 0.812 59.778
13 Kubau 48 0.812 32.667
14 Lere 48 0.812 27.889
No description has been provided for this image
Facilities were open on arrival in 80.1% of 1104 visits. Where closed, data champions waited 46 minutes on average. Kauru is the weakest LGA (68.8%) and Sanga the strongest (93.8%).

Q02. What are the main reasons facilities are closed when visited?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q02(con):
    sql = """
    SELECT reason_closed_on_arrival AS reason, COUNT(*) AS n
    FROM v_visits WHERE facility_open_on_arrival_flag=0 GROUP BY 1 ORDER BY n DESC"""
    df = q(con, sql); df["share"] = df.n / df.n.sum()
    fig = hbar(df, "share", "reason", "Why facilities were closed on arrival", xlabel="Share of closed-on-arrival visits")
    top = df.iloc[0]
    ans = (f"The leading cause of a closed facility is '{top.reason}' ({pct(top.share)} of {int(df.n.sum())} cases). "
           f"Insecurity accounts for {pct(df.loc[df.reason=='Facility closed due to insecurity','share'].sum())}, concentrated in security-risk LGAs.")
    return Result("Q02", "Facility access", "What are the main reasons facilities are closed when visited?", sql, df, ans, fig)
r = q02(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
reason n share
0 Staff not yet arrived 66 0.300
1 Facility closed due to insecurity 39 0.177
2 Staff at LGA meeting or training 35 0.159
3 Staff gone to collect vaccines/commodities 27 0.123
4 Other 23 0.105
5 No staff posted on this shift 20 0.091
6 Public holiday 10 0.045
No description has been provided for this image
The leading cause of a closed facility is 'Staff not yet arrived' (30.0% of 220 cases). Insecurity accounts for 17.7%, concentrated in security-risk LGAs.

Q03. What are facility operating hours, and can facilities refer emergencies with transport?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q03(con):
    sql = """
    SELECT facility_type, hours_of_operation, COUNT(DISTINCT facility_id) AS facilities
    FROM v_visits WHERE round_number=1 GROUP BY 1,2"""
    df = q(con, sql)
    fig = px.bar(df, x="facility_type", y="facilities", color="hours_of_operation", barmode="stack",
                 title="Hours of operation by facility type (baseline)",
                 category_orders={"hours_of_operation": C.HOURS_OF_OPERATION, "facility_type": C.FACILITY_TYPES},
                 color_discrete_sequence=T.SERIES)
    fig.update_traces(marker_line_color="#fff", marker_line_width=2, width=0.6)
    fig.update_layout(xaxis_title="", yaxis_title="Facilities", legend_title="")
    tot = df.groupby("hours_of_operation").facilities.sum(); share24 = tot.get("24 hours", 0) / tot.sum()
    ref = q(con, "SELECT AVG(can_refer_emergencies_flag) r, AVG(emergency_transport_available_2wks_flag) t FROM v_visits").iloc[0]
    ans = (f"{pct(share24)} of facilities report 24-hour operation, driven by PHCs and General Hospitals; most Health Posts run 6-8 hour days. "
           f"{pct(ref.r)} of visits confirmed the facility can refer obstetric emergencies, but transport was available when needed in only {pct(ref.t)} of those.")
    return Result("Q03", "Facility access", "What are facility operating hours, and can facilities refer emergencies with transport?", sql, df, ans, fig,
                  key_numbers={"share_24h": float(share24), "can_refer": float(ref.r), "transport_available": float(ref.t)})
r = q03(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
facility_type hours_of_operation facilities
0 Basic Health Centre 12 hours 12
1 Basic Health Centre 24 hours 10
2 Basic Health Centre 6-8 hours 19
3 Basic Health Centre <5 hours 7
4 General Hospital 12 hours 6
5 General Hospital 24 hours 9
6 General Hospital 6-8 hours 3
7 Health Post 12 hours 8
8 Health Post 24 hours 9
9 Health Post 6-8 hours 16
10 Health Post <5 hours 1
11 Primary Health Centre 12 hours 19
12 Primary Health Centre 24 hours 49
13 Primary Health Centre 6-8 hours 14
14 Primary Health Centre <5 hours 2
No description has been provided for this image
41.8% of facilities report 24-hour operation, driven by PHCs and General Hospitals; most Health Posts run 6-8 hour days. 78.2% of visits confirmed the facility can refer obstetric emergencies, but transport was available when needed in only 59.1% of those.

Cold chain¶

Q04. How functional is the cold chain, and how often is it interrupted?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q04(con):
    sql = """
    SELECT cce_type, AVG(available_flag) AS availability,
           AVG(CASE WHEN available_flag=1 THEN functional_flag END) AS functionality
    FROM cold_chain_equipment GROUP BY cce_type ORDER BY functionality"""
    df = q(con, sql)
    long = df.melt(id_vars="cce_type", var_name="metric", value_name="rate")
    fig = px.bar(long, x="rate", y="cce_type", color="metric", barmode="group", orientation="h",
                 title="Cold chain equipment: availability vs functionality", color_discrete_sequence=T.SERIES, text="rate")
    fig.update_traces(texttemplate="%{text:.0%}", textposition="outside", cliponaxis=False, width=0.35)
    fig.update_layout(xaxis_tickformat=".0%", xaxis_title="", yaxis_title="", legend_title="", height=420)
    intr = q(con, """SELECT AVG(cold_chain_interruption_since_last_visit_flag) r FROM v_visits WHERE has_cold_chain_equipment_flag=1""").iloc[0].r
    reason = q(con, """SELECT cold_chain_interruption_reason reason, COUNT(*) n FROM v_visits WHERE cold_chain_interruption_since_last_visit_flag=1 GROUP BY 1 ORDER BY n DESC""").iloc[0]
    fridge = q(con, "SELECT AVG(vaccine_fridge_functional_flag) r FROM v_visits WHERE has_cold_chain_equipment_flag=1").iloc[0].r
    ans = (f"Where equipment exists, functionality ranges from {pct(df.functionality.min())} ({df.iloc[0].cce_type}) to {pct(df.functionality.max())} ({df.iloc[-1].cce_type}). "
           f"Only {pct(fridge)} of facilities with cold chain equipment have at least one working vaccine refrigerator, and {pct(intr)} of visits recorded a cold chain interruption since the previous visit, "
           f"most often '{reason.reason}'.")
    return Result("Q04", "Cold chain", "How functional is the cold chain, and how often is it interrupted?", sql, df, ans, fig,
                  key_numbers={"fridge_functional_rate": float(fridge), "interruption_rate": float(intr)})
r = q04(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
cce_type availability functionality
0 Rush (3L) 0.277 0.745
1 Cold Boxes (6L) 0.598 0.774
2 Solar Refrigerators and Freezers 0.527 0.789
3 Ice Packs 0.701 0.793
4 GioStyle (4L) 0.462 0.796
5 Temperature Monitoring Devices 0.592 0.804
6 Refrigerators 0.245 0.822
No description has been provided for this image
Where equipment exists, functionality ranges from 74.5% (Rush (3L)) to 82.2% (Refrigerators). Only 66.4% of facilities with cold chain equipment have at least one working vaccine refrigerator, and 20.7% of visits recorded a cold chain interruption since the previous visit, most often 'Power failure / no electricity'.

Service delivery¶

Q05. Which services do facilities offer, and how reliably are planned sessions delivered?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q05(con):
    sql = """
    SELECT service, AVG(offered_flag) AS offered_rate,
           AVG(all_conducted_flag) AS session_completion_rate
    FROM service_sessions GROUP BY service ORDER BY session_completion_rate"""
    df = q(con, sql)
    long = df.melt(id_vars="service", var_name="metric", value_name="rate")
    fig = px.bar(long, x="rate", y="service", color="metric", barmode="group", orientation="h",
                 title="Service availability and completion of planned sessions (last week)", color_discrete_sequence=T.SERIES, text="rate")
    fig.update_traces(texttemplate="%{text:.0%}", textposition="outside", cliponaxis=False, width=0.35)
    fig.update_layout(xaxis_tickformat=".0%", xaxis_title="", yaxis_title="", legend_title="", height=440)
    ans = (f"Malaria and ANC are near-universal, while Nutrition ({pct(df.set_index('service').loc['Nutrition','offered_rate'])}) and Labour & Delivery are the least available. "
           f"Planned sessions were fully delivered in {pct(df.session_completion_rate.mean())} of service-weeks; {df.iloc[0].service} has the lowest completion ({pct(df.iloc[0].session_completion_rate)}).")
    return Result("Q05", "Service delivery", "Which services do facilities offer, and how reliably are planned sessions delivered?", sql, df, ans, fig)
r = q05(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
service offered_rate session_completion_rate
0 Immunization 0.810 0.754
1 Nutrition 0.696 0.763
2 Labour and Delivery 0.772 0.775
3 Family Planning 0.891 0.779
4 Antenatal Care 0.897 0.788
5 IMCI 0.837 0.789
6 Postnatal Care 0.891 0.791
7 Malaria 0.973 0.798
No description has been provided for this image
Malaria and ANC are near-universal, while Nutrition (69.6%) and Labour & Delivery are the least available. Planned sessions were fully delivered in 78.0% of service-weeks; Immunization has the lowest completion (75.4%).

Q06. What stops planned sessions from happening?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q06(con):
    sql = """
    SELECT reason_not_conducted AS reason, COUNT(*) AS n
    FROM service_sessions WHERE all_conducted_flag=0 GROUP BY 1 ORDER BY n DESC"""
    df = q(con, sql); df["share"] = df.n / df.n.sum()
    fig = hbar(df, "share", "reason", "Why planned sessions were missed", xlabel="Share of missed-session reports")
    top = df.iloc[0]
    ans = (f"'{top.reason}' explains {pct(top.share)} of missed sessions - a human-resource problem before a supply problem. "
           f"Security concerns account for {pct(df.loc[df.reason.str.startswith('Security'),'share'].sum())} and vaccine or cold-chain issues for "
           f"{pct(df.loc[df.reason.isin(['Vaccine stock-out','Cold chain equipment failure']),'share'].sum())}.")
    return Result("Q06", "Service delivery", "What stops planned sessions from happening?", sql, df, ans, fig)
r = q06(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
reason n share
0 Staff unavailable (leave, redeployment, strike) 535 0.326
1 Attending training, meeting, or other official... 245 0.149
2 Facility closed (holiday, strike, renovation) 199 0.121
3 Low community turnout / community resistance 168 0.103
4 Security concerns (insecurity, movement restri... 167 0.102
5 Lack of transportation or logistics support 104 0.063
6 Weather or environmental conditions 97 0.059
7 Other 48 0.029
8 Vaccine stock-out 46 0.028
9 Cold chain equipment failure 30 0.018
No description has been provided for this image
'Staff unavailable (leave, redeployment, strike)' explains 32.6% of missed sessions - a human-resource problem before a supply problem. Security concerns account for 10.2% and vaccine or cold-chain issues for 4.6%.

Q07. What is training coverage across the network?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q07(con):
    sql = """
    SELECT t.training, COUNT(DISTINCT t.facility_id)*1.0/(SELECT COUNT(*) FROM facilities) AS coverage
    FROM trainings t GROUP BY 1 ORDER BY coverage DESC"""
    df = q(con, sql)
    trained = q(con, "SELECT AVG(staff_trained_past_2_years_flag) r FROM v_visits WHERE round_number=1").iloc[0].r
    fig = hbar(df, "coverage", "training", "Facilities with staff trained in the past 2 years, by training type", xlabel="Share of facilities")
    ans = (f"{pct(trained)} of facilities had any staff trained in the past two years. Coverage is highest for {df.iloc[0].training} ({pct(df.iloc[0].coverage)}) "
           f"and lowest for {df.iloc[-1].training} ({pct(df.iloc[-1].coverage)}); no single training reaches half of facilities.")
    return Result("Q07", "Service delivery", "What is training coverage across the network?", sql, df, ans, fig, key_numbers={"any_training_rate": float(trained)})
r = q07(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
training coverage
0 Life Saving Skills 0.283
1 Postpartum Family Planning 0.266
2 New Vaccine Introduction (HPV, MR) 0.261
3 Malaria Case Management 0.255
4 Data Management (DHIS2) 0.250
5 Basic Emergency Obstetric and Newborn Care 0.234
6 Infection Prevention and Control 0.217
7 IMCI 0.217
8 Vaccine Management 0.212
9 Modified Life Saving Skills 0.207
10 RI Refresher Training Modules 0.201
11 Family Planning (Short term) 0.196
12 Family Planning (LARC) 0.190
13 Post Abortion Care 0.185
14 Nutrition / IYCF 0.185
No description has been provided for this image
77.2% of facilities had any staff trained in the past two years. Coverage is highest for Life Saving Skills (28.3%) and lowest for Nutrition / IYCF (18.5%); no single training reaches half of facilities.

Human resources¶

Q08. What does the workforce look like by cadre and employment type?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q08(con):
    sql = """
    SELECT cadre, SUM(permanent) AS permanent, SUM(adhoc_a) AS adhoc_a, SUM(adhoc_b) AS adhoc_b, SUM(volunteer) AS volunteer
    FROM v_staffing WHERE round_number=1 GROUP BY cadre ORDER BY permanent DESC"""
    df = q(con, sql)
    long = df.melt(id_vars="cadre", var_name="employment", value_name="headcount")
    fig = px.bar(long, x="headcount", y="cadre", color="employment", orientation="h", title="Workforce composition by cadre (baseline headcount)",
                 color_discrete_sequence=T.SERIES)
    fig.update_traces(marker_line_color="#fff", marker_line_width=2, width=0.6)
    fig.update_layout(yaxis=dict(categoryorder="total ascending"), xaxis_title="Health workers", yaxis_title="", legend_title="", height=440)
    tot = df[["permanent", "adhoc_a", "adhoc_b", "volunteer"]].sum()
    gender = q(con, "SELECT SUM(male_health_workers) m, SUM(female_health_workers) f FROM v_visits WHERE round_number=1").iloc[0]
    nodoc = q(con, "SELECT AVG(doctors=0) r FROM v_visits WHERE round_number=1 AND facility_type='Primary Health Centre'").iloc[0].r
    nonurse = q(con, "SELECT AVG(nurses_midwives=0) r FROM v_visits WHERE round_number=1 AND facility_type IN ('Primary Health Centre','Basic Health Centre')").iloc[0].r
    ans = (f"The network has {int(tot.sum()):,} health workers: {pct(tot.permanent/tot.sum())} permanent, {pct((tot.adhoc_a+tot.adhoc_b)/tot.sum())} ad hoc/seconded and {pct(tot.volunteer/tot.sum())} volunteers. "
           f"CHEWs and JCHEWs form the backbone; {pct(nodoc)} of PHCs have no permanent doctor and {pct(nonurse)} of PHCs/BHCs have no permanent nurse or midwife. "
           f"Women make up {pct(gender.f/(gender.m+gender.f))} of the workforce.")
    return Result("Q08", "Human resources", "What does the workforce look like by cadre and employment type?", sql, df, ans, fig,
                  key_numbers={"health_workers": int(tot.sum()), "phc_without_doctor": float(nodoc), "phc_bhc_without_nurse": float(nonurse)})
r = q08(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
cadre permanent adhoc_a adhoc_b volunteer
0 Nurse/Midwife 762 190 83 15
1 CHEW 691 156 66 177
2 JCHEW 467 125 74 162
3 Medical Lab Technician 238 16 70 19
4 CHO 236 54 0 23
5 Medical Officer 195 11 0 22
6 Pharmacy Technician 190 14 76 16
7 Medical Records Officer 172 17 0 186
8 Environmental Health Officer 93 13 0 23
No description has been provided for this image
The network has 4,652 health workers: 65.4% permanent, 20.7% ad hoc/seconded and 13.8% volunteers. CHEWs and JCHEWs form the backbone; 38.1% of PHCs have no permanent doctor and 9.1% of PHCs/BHCs have no permanent nurse or midwife. Women make up 55.1% of the workforce.

Q09. How high is absenteeism, and which cadres are most affected?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q09(con):
    sql = """
    SELECT cadre, SUM(permanent_present_today)*1.0/SUM(permanent_scheduled_today) AS attendance_rate, SUM(permanent_scheduled_today) AS scheduled
    FROM v_staffing WHERE permanent_scheduled_today>0 GROUP BY cadre ORDER BY attendance_rate"""
    df = q(con, sql)
    fig = hbar(df, "attendance_rate", "cadre", "Attendance rate of permanent staff scheduled for duty, by cadre", xlabel="Present / scheduled")
    overall = q(con, "SELECT SUM(permanent_present_today)*1.0/SUM(permanent_scheduled_today) r FROM staffing_by_cadre").iloc[0].r
    fig.add_vline(x=overall, line_dash="dot", line_color=T.TEXT_SECONDARY, annotation_text=f"Overall {pct(overall)}")
    ans = (f"Across all visits, {pct(overall)} of permanent staff scheduled for duty were actually present - an absenteeism rate of {pct(1-overall)}. "
           f"{df.iloc[0].cadre}s are least reliably present ({pct(df.iloc[0].attendance_rate)}); {df.iloc[-1].cadre}s most ({pct(df.iloc[-1].attendance_rate)}).")
    return Result("Q09", "Human resources", "How high is absenteeism, and which cadres are most affected?", sql, df, ans, fig, key_numbers={"attendance_rate": float(overall)})
r = q09(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
cadre attendance_rate scheduled
0 JCHEW 0.686 2461
1 CHO 0.704 1252
2 CHEW 0.713 3649
3 Medical Records Officer 0.724 881
4 Environmental Health Officer 0.727 476
5 Nurse/Midwife 0.732 3996
6 Medical Officer 0.735 1017
7 Medical Lab Technician 0.737 1260
8 Pharmacy Technician 0.758 987
No description has been provided for this image
Across all visits, 72.0% of permanent staff scheduled for duty were actually present - an absenteeism rate of 28.0%. JCHEWs are least reliably present (68.6%); Pharmacy Technicians most (75.8%).

Q10. Where is absenteeism geographically concentrated?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q10(con):
    sql = """
    SELECT lga, SUM(permanent_present_today)*1.0/SUM(permanent_scheduled_today) AS attendance_rate
    FROM v_staffing WHERE permanent_scheduled_today>0 GROUP BY lga ORDER BY attendance_rate"""
    df = q(con, sql)
    fig = hbar(df, "attendance_rate", "lga", "Permanent staff attendance rate by LGA", xlabel="Present / scheduled")
    ans = (f"Attendance ranges from {pct(df.iloc[0].attendance_rate)} in {df.iloc[0].lga} to {pct(df.iloc[-1].attendance_rate)} in {df.iloc[-1].lga} - "
           f"a {100*(df.iloc[-1].attendance_rate-df.iloc[0].attendance_rate):.0f}-point spread that points to LGA-level management and security effects rather than individual behaviour.")
    return Result("Q10", "Human resources", "Where is absenteeism geographically concentrated?", sql, df, ans, fig)
r = q10(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
lga attendance_rate
0 Igabi 0.576
1 Giwa 0.627
2 Kubau 0.642
3 Chikun 0.666
4 Kauru 0.674
5 Makarfi 0.686
6 Kudan 0.698
7 Kachia 0.701
8 Ikara 0.707
9 Kajuru 0.715
10 Jema'a 0.719
11 Lere 0.722
12 Jaba 0.724
13 Zangon Kataf 0.725
14 Soba 0.737
No description has been provided for this image
Attendance ranges from 57.6% in Igabi to 79.7% in Sanga - a 22-point spread that points to LGA-level management and security effects rather than individual behaviour.

Q11. Why are staff absent?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q11(con):
    sql = """
    SELECT reason, SUM(staff_count) AS staff FROM v_absence GROUP BY reason ORDER BY staff DESC"""
    df = q(con, sql); df["share"] = df.staff / df.staff.sum()
    fig = hbar(df.head(12), "share", "reason", "Top reasons permanent staff were absent (share of absent staff)", xlabel="Share of absences")
    salary_related = df.loc[df.reason.isin(["Access to Salary", "Dissatisfaction with Salary and Benefits"]), "share"].sum()
    ans = (f"'{df.iloc[0].reason}' is the top recorded reason ({pct(df.iloc[0].share)}), followed by '{df.iloc[1].reason}' ({pct(df.iloc[1].share)}). "
           f"Salary-linked absence (going to access salary, or dissatisfaction with pay) accounts for {pct(salary_related)} of all absences and is directly addressable by payment reform.")
    return Result("Q11", "Human resources", "Why are staff absent?", sql, df, ans, fig, key_numbers={"salary_related_absence_share": float(salary_related)})
r = q11(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
reason staff share
0 Leave (Study, Annual, Maternity, etc.) 501 0.112
1 Access to Salary 403 0.090
2 Personal Health Issues 375 0.084
3 Shift 364 0.081
4 Long Distance to Workplace 337 0.075
5 Transportation Problems 324 0.072
6 Workshop and Training 317 0.071
7 Family Responsibilities 307 0.069
8 Dissatisfaction with Salary and Benefits 286 0.064
9 Meetings 265 0.059
10 Funeral 188 0.042
11 Weddings 137 0.031
12 Dissatisfaction with Job 134 0.030
13 Unrest 122 0.027
14 High Workload and Stress 114 0.025
No description has been provided for this image
'Leave (Study, Annual, Maternity, etc.)' is the top recorded reason (11.2%), followed by 'Access to Salary' (9.0%). Salary-linked absence (going to access salary, or dissatisfaction with pay) accounts for 15.4% of all absences and is directly addressable by payment reform.

Q12. How do salary delays affect attendance and service delivery over time?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q12(con):
    sql = """
    SELECT round_number, round_name,
           AVG(salary_paid_on_time_last_3_months_flag) AS salary_on_time,
           SUM(permanent_present_today)*1.0/SUM(permanent_scheduled_today) AS attendance_rate,
           AVG(session_completion_rate) AS session_completion
    FROM v_visits GROUP BY 1,2 ORDER BY 1"""
    df = q(con, sql)
    fig = go.Figure()
    for col, name in [("salary_on_time", "Salary paid on time"), ("attendance_rate", "Staff attendance"), ("session_completion", "Sessions completed")]:
        fig.add_trace(go.Scatter(x=df.round_name, y=df[col], mode="lines+markers", name=name, line=dict(width=2), marker=dict(size=9)))
    fig.update_layout(title="Salary timeliness, attendance and session completion by visit round", yaxis_tickformat=".0%", xaxis_title="", yaxis_title="", hovermode="x unified")
    shock = df.loc[df.salary_on_time.idxmin()]
    normal = df[df.round_number != shock.round_number]
    ans = (f"In {shock.round_name} salary timeliness collapsed to {pct(shock.salary_on_time)} (vs {pct(normal.salary_on_time.mean())} in other rounds); "
           f"attendance fell to {pct(shock.attendance_rate)} (vs {pct(normal.attendance_rate.mean())}) and session completion to {pct(shock.session_completion)} in the same round. "
           f"Late salaries translate almost immediately into empty duty posts.")
    return Result("Q12", "Human resources", "How do salary delays affect attendance and service delivery over time?", sql, df, ans, fig)
r = q12(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
round_number round_name salary_on_time attendance_rate session_completion
0 1 Baseline 0.799 0.745 0.773
1 2 Bi-weekly 1 0.848 0.751 0.817
2 3 Bi-weekly 2 0.728 0.723 0.785
3 4 Bi-weekly 3 0.141 0.637 0.710
4 5 Bi-weekly 4 0.815 0.739 0.805
5 6 Bi-weekly 5 0.755 0.725 0.788
No description has been provided for this image
In Bi-weekly 3 salary timeliness collapsed to 14.1% (vs 78.9% in other rounds); attendance fell to 63.7% (vs 73.7%) and session completion to 71.0% in the same round. Late salaries translate almost immediately into empty duty posts.

Q13. Do roster practices and salary timeliness independently predict attendance?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q13(con):
    sql = """
    SELECT CASE WHEN salary_paid_on_time_last_3_months_flag=1 THEN 'Salary on time' ELSE 'Salary delayed' END AS salary,
           CASE WHEN roster_updated_this_week_flag=1 THEN 'Roster updated' ELSE 'Roster not updated' END AS roster,
           SUM(permanent_present_today)*1.0/SUM(permanent_scheduled_today) AS attendance_rate, COUNT(*) AS visits
    FROM v_visits GROUP BY 1,2"""
    df = q(con, sql)
    fig = px.bar(df, x="salary", y="attendance_rate", color="roster", barmode="group", text="attendance_rate",
                 title="Attendance by salary timeliness and roster practice", color_discrete_sequence=T.SERIES)
    fig.update_traces(texttemplate="%{text:.1%}", textposition="outside", width=0.3)
    fig.update_layout(yaxis_tickformat=".0%", xaxis_title="", yaxis_title="Attendance rate", legend_title="", yaxis_range=[0, 1])
    piv = df.pivot(index="salary", columns="roster", values="attendance_rate")
    ans = (f"When salary is on time and the roster was updated this week, attendance is {pct(piv.loc['Salary on time','Roster updated'])}; "
           f"with delayed salary and a stale roster it drops to {pct(piv.loc['Salary delayed','Roster not updated'])}. "
           f"Both levers matter, and roster discipline partly cushions the salary effect.")
    return Result("Q13", "Human resources", "Do roster practices and salary timeliness independently predict attendance?", sql, df, ans, fig)
r = q13(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
salary roster attendance_rate visits
0 Salary delayed Roster not updated 0.550 188
1 Salary delayed Roster updated 0.671 164
2 Salary on time Roster not updated 0.703 374
3 Salary on time Roster updated 0.805 378
No description has been provided for this image
When salary is on time and the roster was updated this week, attendance is 80.5%; with delayed salary and a stale roster it drops to 55.0%. Both levers matter, and roster discipline partly cushions the salary effect.

Q14. How much service time is lost to accessing salary payments?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q14(con):
    sql = """
    SELECT staff_leave_facility_for_salary_frequency AS frequency, urban_rural, COUNT(*) AS visits
    FROM v_visits GROUP BY 1,2"""
    df = q(con, sql); df["share"] = df.visits / df.groupby("urban_rural").visits.transform("sum")
    fig = px.bar(df, x="frequency", y="share", color="urban_rural", barmode="group", text="share",
                 category_orders={"frequency": C.LEAVE_FOR_SALARY_FREQ}, title="How often staff leave the facility to access their salary",
                 color_discrete_sequence=T.SERIES)
    fig.update_traces(texttemplate="%{text:.0%}", textposition="outside", width=0.3)
    fig.update_layout(yaxis_tickformat=".0%", xaxis_title="", yaxis_title="Share of visits", legend_title="")
    reason = q(con, "SELECT leave_for_salary_reason r, COUNT(*) n FROM v_visits WHERE leave_for_salary_reason IS NOT NULL GROUP BY 1 ORDER BY n DESC").iloc[0]
    rural_freq = df[(df.urban_rural == "Rural") & df.frequency.isin(["2-3 times a month", "Weekly"])].share.sum()
    ans = (f"In rural facilities, staff leave the post to access salary two or more times a month in {pct(rural_freq)} of visits; the main reason is '{reason.r}'. "
           f"Bringing agent banking or POS access to facility catchments would recover many lost duty days.")
    return Result("Q14", "Human resources", "How much service time is lost to accessing salary payments?", sql, df, ans, fig)
r = q14(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
frequency urban_rural visits share
0 2-3 times a month Rural 272 0.296
1 2-3 times a month Urban 52 0.280
2 Never Rural 106 0.115
3 Never Urban 51 0.274
4 Once a month Rural 361 0.393
5 Once a month Urban 83 0.446
6 Weekly Rural 179 0.195
No description has been provided for this image
In rural facilities, staff leave the post to access salary two or more times a month in 49.1% of visits; the main reason is 'No bank or ATM nearby'. Bringing agent banking or POS access to facility catchments would recover many lost duty days.

Supply chain¶

Q15. Which essential medicines and commodities are most often stocked out?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q15(con):
    sql = """
    SELECT commodity, category, AVG(stockout_flag) AS stockout_rate, AVG(zero_balance_flag) AS zero_balance_rate,
           AVG(below_min_stock_flag) AS below_min_rate, COUNT(*) AS observations
    FROM v_commodity WHERE stocked_flag=1 GROUP BY 1,2 ORDER BY stockout_rate DESC"""
    df = q(con, sql)
    fig = hbar(df, "stockout_rate", "commodity", "Stock-out rate since last visit, by tracer commodity", color="category", xlabel="Share of visits with a stock-out")
    fig.update_layout(legend_title="")
    overall = q(con, "SELECT AVG(stockout_flag) r FROM commodity_stock WHERE stocked_flag=1").iloc[0].r
    mat = df[df.category == "Maternal"].stockout_rate.mean()
    ans = (f"On average {pct(overall)} of stocked tracer items had a stock-out since the previous visit. Maternal life-saving commodities fare worst "
           f"(mean {pct(mat)}): {df.iloc[0].commodity} ({pct(df.iloc[0].stockout_rate)}), {df.iloc[1].commodity} ({pct(df.iloc[1].stockout_rate)}) and {df.iloc[2].commodity} ({pct(df.iloc[2].stockout_rate)}). "
           f"{df.iloc[-1].commodity} is the most reliably available ({pct(df.iloc[-1].stockout_rate)}).")
    return Result("Q15", "Supply chain", "Which essential medicines and commodities are most often stocked out?", sql, df, ans, fig,
                  key_numbers={"overall_stockout_rate": float(overall), "maternal_stockout_rate": float(mat)})
r = q15(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
commodity category stockout_rate zero_balance_rate below_min_rate observations
0 Calibrated Blood Collection Drape Maternal 0.554 0.582 0.958 852
1 Magnesium Sulphate Injection Maternal 0.427 0.453 0.911 852
2 Tranexamic Acid Injection Maternal 0.426 0.477 0.918 852
3 Implants Family Planning 0.403 0.440 0.912 984
4 Chlorhexidine Gel Child 0.387 0.414 0.932 1104
5 Ibuprofen Suspension Child 0.374 0.390 0.859 1104
6 Misoprostol Maternal 0.351 0.353 0.857 852
7 Artemether Injection General 0.329 0.341 0.880 1104
8 Ferrous Sulphate Maternal 0.317 0.318 0.847 1104
9 Zinc Child 0.302 0.305 0.823 1104
10 Injectable Contraceptives Family Planning 0.293 0.302 0.850 984
11 Paracetamol Syrup Child 0.293 0.298 0.844 1104
12 Amoxicillin Capsule General 0.275 0.275 0.831 1104
13 Amoxicillin Dispersible Child 0.275 0.287 0.821 1104
14 IV Fluids Maternal 0.275 0.284 0.815 1104
No description has been provided for this image
On average 31.4% of stocked tracer items had a stock-out since the previous visit. Maternal life-saving commodities fare worst (mean 33.9%): Calibrated Blood Collection Drape (55.4%), Magnesium Sulphate Injection (42.7%) and Tranexamic Acid Injection (42.6%). Paracetamol Tablet is the most reliably available (22.7%).

Q16. Are stock-outs caused upstream or at the facility?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q16(con):
    sql = """
    SELECT stockout_reason AS reason, COUNT(*) AS n FROM commodity_stock WHERE stockout_flag=1 GROUP BY 1 ORDER BY n DESC"""
    df = q(con, sql); df["share"] = df.n / df.n.sum()
    fig = hbar(df, "share", "reason", "Main reasons for stock-outs", xlabel="Share of stock-out events")
    upstream = df.loc[df.reason.isin(["Stock-out at LGA/state/central supply level", "Delay in delivery or distribution", "Incomplete fulfillment of requisition", "No feedback from KADHSMA"]), "share"].sum()
    facility = df.loc[df.reason.isin(["Requisition not submitted or submitted late", "Poor quantification or forecasting", "Expired or damaged stock not replaced"]), "share"].sum()
    ans = (f"{pct(upstream)} of stock-outs are attributed to upstream causes (central stock-out, delivery delay, partial fulfilment, no feedback), versus {pct(facility)} to facility-side causes "
           f"(late requisition, poor forecasting, unreplaced expiries). The single largest is '{df.iloc[0].reason}' at {pct(df.iloc[0].share)}.")
    return Result("Q16", "Supply chain", "Are stock-outs caused upstream or at the facility?", sql, df, ans, fig,
                  key_numbers={"upstream_share": float(upstream), "facility_share": float(facility)})
r = q16(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
reason n share
0 Stock-out at LGA/state/central supply level 2323 0.310
1 Delay in delivery or distribution 1211 0.161
2 Requisition not submitted or submitted late 731 0.097
3 High demand or unexpected increase in consumption 652 0.087
4 Poor quantification or forecasting 552 0.074
5 Insufficient funds for procurement 533 0.071
6 Incomplete fulfillment of requisition 444 0.059
7 No feedback from KADHSMA 393 0.052
8 Expired or damaged stock not replaced 265 0.035
9 Other 199 0.027
10 Given a non-requisitioned essential medicine o... 160 0.021
11 Storage or cold chain failure 38 0.005
No description has been provided for this image
58.3% of stock-outs are attributed to upstream causes (central stock-out, delivery delay, partial fulfilment, no feedback), versus 20.6% to facility-side causes (late requisition, poor forecasting, unreplaced expiries). The single largest is 'Stock-out at LGA/state/central supply level' at 31.0%.

Q17. Which LGAs have the worst commodity availability?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q17(con):
    sql = """
    SELECT lga, AVG(stockout_flag) AS stockout_rate FROM v_commodity WHERE stocked_flag=1 GROUP BY lga ORDER BY stockout_rate DESC"""
    df = q(con, sql)
    fig = hbar(df, "stockout_rate", "lga", "Tracer commodity stock-out rate by LGA", xlabel="Share of stocked items with a stock-out")
    ans = (f"Stock-out rates range from {pct(df.iloc[-1].stockout_rate)} in {df.iloc[-1].lga} to {pct(df.iloc[0].stockout_rate)} in {df.iloc[0].lga}. "
           f"The top-five LGAs ({', '.join(df.head(5).lga)}) should be prioritised for supply-chain supervision.")
    return Result("Q17", "Supply chain", "Which LGAs have the worst commodity availability?", sql, df, ans, fig)
r = q17(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
lga stockout_rate
0 Kauru 0.422
1 Chikun 0.406
2 Kudan 0.406
3 Igabi 0.374
4 Giwa 0.367
5 Jema'a 0.362
6 Lere 0.359
7 Kajuru 0.343
8 Kaduna South 0.342
9 Zaria 0.337
10 Kagarko 0.337
11 Kubau 0.335
12 Kaduna North 0.328
13 Kachia 0.288
14 Kaura 0.284
No description has been provided for this image
Stock-out rates range from 18.1% in Sanga to 42.2% in Kauru. The top-five LGAs (Kauru, Chikun, Kudan, Igabi, Giwa) should be prioritised for supply-chain supervision.

Q18. How well does the requisition-to-delivery process work?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q18(con):
    sql = """
    SELECT requisition_frequency, AVG(requisition_submitted_last_cycle_flag) AS submitted,
           AVG(requisition_complete_on_time_flag) AS complete_on_time,
           AVG(CASE WHEN requisition_receipt_status='Yes, all items received' THEN 1.0 WHEN requisition_receipt_status IS NULL THEN NULL ELSE 0 END) AS fully_received,
           AVG(delivery_documentation_provided_flag) AS documented, COUNT(*) AS visits
    FROM v_visits GROUP BY 1 ORDER BY visits DESC"""
    df = q(con, sql)
    long = df.melt(id_vars=["requisition_frequency", "visits"], var_name="stage", value_name="rate").dropna()
    fig = px.bar(long, x="stage", y="rate", color="requisition_frequency", barmode="group", text="rate",
                 title="Requisition funnel: submitted, complete & on time, fully received, documented", color_discrete_sequence=T.SERIES,
                 category_orders={"stage": ["submitted", "complete_on_time", "fully_received", "documented"]})
    fig.update_traces(texttemplate="%{text:.0%}", textposition="outside", width=0.18)
    fig.update_layout(yaxis_tickformat=".0%", xaxis_title="", yaxis_title="", legend_title="Requisition frequency")
    o = q(con, """SELECT AVG(requisition_submitted_last_cycle_flag) s, AVG(requisition_complete_on_time_flag) c,
                  AVG(CASE WHEN requisition_receipt_status='Yes, all items received' THEN 1.0 WHEN requisition_receipt_status IS NULL THEN NULL ELSE 0 END) f,
                  AVG(delivery_documentation_provided_flag) d FROM v_visits""").iloc[0]
    ans = (f"Only {pct(o.s)} of facilities submitted a requisition in the last cycle; of those, {pct(o.c)} were complete and on time and {pct(o.f)} were fully filled. "
           f"Delivery documentation accompanied {pct(o.d)} of receipts. Each leak in this funnel compounds into the stock-out rates above.")
    return Result("Q18", "Supply chain", "How well does the requisition-to-delivery process work?", sql, df, ans, fig,
                  key_numbers={"requisition_submitted": float(o.s), "complete_on_time": float(o.c), "fully_received": float(o.f)})
r = q18(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
requisition_frequency submitted complete_on_time fully_received documented visits
0 Monthly 0.705 0.714 0.475 0.789 624
1 Bi-monthly 0.725 0.696 0.497 0.832 222
2 We do not submit requisitions 0.000 NaN NaN NaN 150
3 Quarterly 0.741 0.750 0.550 0.739 108
No description has been provided for this image
Only 61.7% of facilities submitted a requisition in the last cycle; of those, 71.4% were complete and on time and 48.9% were fully filled. Delivery documentation accompanied 79.4% of receipts. Each leak in this funnel compounds into the stock-out rates above.

Q19. Do requisition discipline and stock-management skills reduce stock-outs?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q19(con):
    sql = """
    SELECT CASE WHEN requisition_submitted_last_cycle_flag=1 THEN 'Requisition submitted' ELSE 'No requisition' END AS requisition,
           knows_min_stock_calculation, AVG(stockout_flag) AS stockout_rate, COUNT(*) AS n
    FROM v_commodity WHERE stocked_flag=1 GROUP BY 1,2"""
    df = q(con, sql)
    fig = px.bar(df, x="knows_min_stock_calculation", y="stockout_rate", color="requisition", barmode="group", text="stockout_rate",
                 title="Stock-out rate by requisition behaviour and minimum-stock knowledge", color_discrete_sequence=T.SERIES)
    fig.update_traces(texttemplate="%{text:.1%}", textposition="outside", width=0.3)
    fig.update_layout(yaxis_tickformat=".0%", xaxis_title="Knows how to calculate minimum stock", yaxis_title="Stock-out rate", legend_title="")
    best = df.loc[df.stockout_rate.idxmin()]; worst = df.loc[df.stockout_rate.idxmax()]
    ans = (f"Facilities that submitted a requisition and know how to calculate minimum stock have a {pct(best.stockout_rate)} stock-out rate, against {pct(worst.stockout_rate)} where "
           f"no requisition was submitted and nobody can calculate minimum stock. Requisition discipline and LMIS skills are the cheapest stock-out reducers available.")
    return Result("Q19", "Supply chain", "Do requisition discipline and stock-management skills reduce stock-outs?", sql, df, ans, fig)
r = q19(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
requisition knows_min_stock_calculation stockout_rate n
0 No requisition No 0.428 2617
1 No requisition Yes, I know how to calculate 0.435 4308
2 No requisition Yes, someone else is assigned to calculate 0.411 2215
3 Requisition submitted No 0.259 4805
4 Requisition submitted Yes, I know how to calculate 0.237 6672
5 Requisition submitted Yes, someone else is assigned to calculate 0.236 3275
No description has been provided for this image
Facilities that submitted a requisition and know how to calculate minimum stock have a 23.6% stock-out rate, against 43.5% where no requisition was submitted and nobody can calculate minimum stock. Requisition discipline and LMIS skills are the cheapest stock-out reducers available.

Q20. Which supply sources are most reliable?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q20(con):
    sql = """
    SELECT supplier, AVG(stockout_flag) AS stockout_rate, COUNT(*) AS n FROM commodity_stock WHERE stocked_flag=1 AND supplier IS NOT NULL GROUP BY 1 HAVING n>200 ORDER BY stockout_rate"""
    df = q(con, sql)
    fig = hbar(df, "stockout_rate", "supplier", "Stock-out rate by commodity supplier", xlabel="Share of items with a stock-out")
    share = q(con, "SELECT supplier, COUNT(*)*1.0/(SELECT COUNT(*) FROM commodity_stock WHERE supplier IS NOT NULL) s FROM commodity_stock WHERE supplier IS NOT NULL GROUP BY 1 ORDER BY s DESC").iloc[0]
    ans = (f"{share.supplier} supplies {pct(share.s)} of stocked items. Items sourced from {df.iloc[0].supplier} show the lowest stock-out rate ({pct(df.iloc[0].stockout_rate)}) "
           f"and {df.iloc[-1].supplier}-sourced items the highest ({pct(df.iloc[-1].stockout_rate)}).")
    return Result("Q20", "Supply chain", "Which supply sources are most reliable?", sql, df, ans, fig)
r = q20(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
supplier stockout_rate n
0 Open Market 0.246 3027
1 Zipline 0.249 2257
2 Free MNCH 0.301 3202
3 LGA 0.309 1350
4 Federal Government 0.320 2604
5 Donations 0.323 1026
6 KDHSMA 0.347 8748
7 NGO 0.365 1678
No description has been provided for this image
KDHSMA supplies 36.6% of stocked items. Items sourced from Open Market show the lowest stock-out rate (24.6%) and NGO-sourced items the highest (36.5%).

Q21. How many facilities are below minimum stock (early-warning)?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q21(con):
    sql = """
    SELECT commodity, AVG(below_min_stock_flag) AS below_min_rate,
           AVG(CASE WHEN stock_adequacy_ratio IS NOT NULL THEN MIN(stock_adequacy_ratio, 5) END) AS median_adequacy
    FROM commodity_stock WHERE stocked_flag=1 AND minimum_stock_level IS NOT NULL GROUP BY 1 ORDER BY below_min_rate DESC"""
    df = q(con, sql)
    fig = hbar(df, "below_min_rate", "commodity", "Share of facilities holding less than their minimum stock level", xlabel="Below minimum stock")
    known = q(con, "SELECT AVG(minimum_stock_level IS NOT NULL) r FROM commodity_stock WHERE stocked_flag=1").iloc[0].r
    ans = (f"A minimum stock level was known for {pct(known)} of stocked items. Where known, {pct(df.below_min_rate.mean())} of items sit below the minimum - "
           f"the facility is one bad fortnight away from a stock-out. {df.iloc[0].commodity} ({pct(df.iloc[0].below_min_rate)}) is most exposed.")
    return Result("Q21", "Supply chain", "How many facilities are below minimum stock (early-warning)?", sql, df, ans, fig, key_numbers={"min_stock_known": float(known)})
r = q21(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
commodity below_min_rate median_adequacy
0 Calibrated Blood Collection Drape 0.958 0.201
1 Chlorhexidine Gel 0.932 0.299
2 Tranexamic Acid Injection 0.918 0.290
3 Implants 0.912 0.310
4 Magnesium Sulphate Injection 0.911 0.318
5 Artemether Injection 0.880 0.420
6 Ibuprofen Tablet 0.865 0.490
7 Ibuprofen Suspension 0.859 0.403
8 Misoprostol 0.857 0.421
9 Injectable Contraceptives 0.850 0.464
10 Ferrous Sulphate 0.847 0.470
11 Paracetamol Syrup 0.844 0.471
12 ORS 0.843 0.481
13 Oxytocin 0.833 0.480
14 Amoxicillin Capsule 0.831 0.493
No description has been provided for this image
A minimum stock level was known for 78.4% of stocked items. Where known, 85.2% of items sit below the minimum - the facility is one bad fortnight away from a stock-out. Calibrated Blood Collection Drape (95.8%) is most exposed.

Vaccines¶

Q22. Which vaccines are available, and where are the gaps?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q22(con):
    sql = """
    SELECT vaccine, AVG(in_stock_flag) AS in_stock_rate, SUM(doses_used) AS doses_used, AVG(physically_verified_flag) AS verified
    FROM vaccine_stock GROUP BY 1 ORDER BY in_stock_rate"""
    df = q(con, sql)
    fig = hbar(df, "in_stock_rate", "vaccine", "Vaccine availability at facilities that stock vaccines", xlabel="Share of visits with vaccine in stock")
    status = q(con, "SELECT vaccine_stock_status s, COUNT(DISTINCT facility_id) n FROM v_visits WHERE round_number=1 GROUP BY 1").set_index("s").n
    tot = status.sum()
    ans = (f"{pct(status.get('Yes',0)/tot)} of facilities stock vaccines on site, {pct(status.get(C.VACCINE_STOCK_STATUS[1],0)/tot)} immunise without stocking and "
           f"{pct(status.get(C.VACCINE_STOCK_STATUS[2],0)/tot)} offer no immunisation. Among stocking facilities, availability is lowest for {df.iloc[0].vaccine} ({pct(df.iloc[0].in_stock_rate)}) "
           f"and {df.iloc[1].vaccine} ({pct(df.iloc[1].in_stock_rate)}) - the newer antigens - and highest for {df.iloc[-1].vaccine} ({pct(df.iloc[-1].in_stock_rate)}).")
    return Result("Q22", "Vaccines", "Which vaccines are available, and where are the gaps?", sql, df, ans, fig)
r = q22(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
vaccine in_stock_rate doses_used verified
0 HPV 0.556 6688 0.510
1 MEN A 0.587 6808 0.543
2 MR 0.597 7772 0.555
3 BCG 0.690 12143 0.636
4 IPV 0.703 13548 0.648
5 PENTA 0.714 26815 0.658
6 PCV 0.725 27587 0.679
7 MCV 0.735 9379 0.682
8 YF 0.756 9343 0.700
9 ROTA 0.765 18656 0.706
10 OPV 0.780 27842 0.697
No description has been provided for this image
64.7% of facilities stock vaccines on site, 25.0% immunise without stocking and 10.3% offer no immunisation. Among stocking facilities, availability is lowest for HPV (55.6%) and MEN A (58.7%) - the newer antigens - and highest for OPV (78.0%).

Q23. How much does cold chain status drive vaccine availability and immunisation delivery?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q23(con):
    sql = """
    SELECT CASE WHEN vaccine_fridge_functional_flag=1 THEN 'Working fridge' ELSE 'No working fridge' END AS fridge,
           CASE WHEN cold_chain_interruption_since_last_visit_flag=1 THEN 'Interruption' ELSE 'No interruption' END AS interruption,
           AVG(in_stock_flag) AS in_stock_rate, COUNT(*) AS n
    FROM v_vaccine GROUP BY 1,2"""
    df = q(con, sql)
    fig = px.bar(df, x="fridge", y="in_stock_rate", color="interruption", barmode="group", text="in_stock_rate",
                 title="Vaccine availability by cold chain status", color_discrete_sequence=T.SERIES)
    fig.update_traces(texttemplate="%{text:.1%}", textposition="outside", width=0.3)
    fig.update_layout(yaxis_tickformat=".0%", xaxis_title="", yaxis_title="Vaccine in stock", legend_title="", yaxis_range=[0, 1])
    imm = q(con, """SELECT vaccine_fridge_functional_flag f, AVG(immunization_sessions_all_conducted_flag) r FROM v_visits WHERE vaccine_stock_status='Yes' GROUP BY 1""").set_index("f").r
    best = df.loc[df.in_stock_rate.idxmax()]; worst = df.loc[df.in_stock_rate.idxmin()]
    ans = (f"Vaccine availability is {pct(best.in_stock_rate)} with a {best.fridge.lower()} and {best.interruption.lower()}, but {pct(worst.in_stock_rate)} with {worst.fridge.lower()} and {worst.interruption.lower()}. "
           f"Immunisation sessions were fully delivered in {pct(imm.get(1, np.nan))} of weeks where the fridge works versus {pct(imm.get(0, np.nan))} where it does not.")
    return Result("Q23", "Vaccines", "How much does cold chain status drive vaccine availability and immunisation delivery?", sql, df, ans, fig)
r = q23(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
fridge interruption in_stock_rate n
0 No working fridge Interruption 0.587 968
1 No working fridge No interruption 0.699 1595
2 Working fridge Interruption 0.582 660
3 Working fridge No interruption 0.727 4631
No description has been provided for this image
Vaccine availability is 72.7% with a working fridge and no interruption, but 58.2% with working fridge and interruption. Immunisation sessions were fully delivered in 81.5% of weeks where the fridge works versus 57.1% where it does not.

Q24. Which vaccines are consumed most, and why?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q24(con):
    sql = """
    SELECT vaccine, SUM(most_dispensed_flag) AS weeks_most_dispensed, SUM(doses_used) AS doses_used FROM vaccine_stock GROUP BY 1 ORDER BY doses_used DESC"""
    df = q(con, sql)
    fig = hbar(df, "doses_used", "vaccine", "Doses used across all visits, by vaccine", xlabel="Doses", fmt=",.0f")
    reason = q(con, "SELECT reason_most_dispensed r, COUNT(*) n FROM vaccine_stock WHERE most_dispensed_flag=1 GROUP BY 1 ORDER BY n DESC")
    ans = (f"{df.iloc[0].vaccine}, {df.iloc[1].vaccine} and {df.iloc[2].vaccine} account for {pct(df.head(3).doses_used.sum()/df.doses_used.sum())} of doses used. "
           f"The most common driver of a high-dispensing week is '{reason.iloc[0].r}' ({pct(reason.iloc[0].n/reason.n.sum())}), and HPV demand is mostly school-based sessions.")
    return Result("Q24", "Vaccines", "Which vaccines are consumed most, and why?", sql, df, ans, fig)
r = q24(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
vaccine weeks_most_dispensed doses_used
0 OPV 511 27842
1 PCV 510 27587
2 PENTA 511 26815
3 ROTA 459 18656
4 IPV 470 13548
5 BCG 453 12143
6 MCV 437 9379
7 YF 416 9343
8 MR 431 7772
9 MEN A 436 6808
10 HPV 436 6688
No description has been provided for this image
OPV, PCV and PENTA account for 49.4% of doses used. The most common driver of a high-dispensing week is 'Scheduled routine immunization session' (53.4%), and HPV demand is mostly school-based sessions.

Readiness¶

Q25. Which LGAs are most and least ready to deliver services?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q25(con):
    sql = """
    SELECT lga, AVG(readiness_score) AS readiness, AVG(facility_open_on_arrival_flag) AS open_rate, AVG(attendance_rate) AS attendance,
           1-AVG(stockout_rate) AS stock_availability, AVG(vaccine_availability_rate) AS vaccine_availability, AVG(session_completion_rate) AS sessions
    FROM v_visits GROUP BY lga ORDER BY readiness DESC"""
    df = q(con, sql)
    fig = hbar(df, "readiness", "lga", "Composite facility readiness score by LGA (0-100)", xlabel="Readiness score", fmt=".1f")
    fig.update_xaxes(tickformat="")
    bands = q(con, "SELECT readiness_band b, COUNT(*) n FROM v_visits GROUP BY 1").set_index("b").n
    ans = (f"Average readiness is {df.readiness.mean():.1f}/100, from {df.iloc[-1].readiness:.1f} in {df.iloc[-1].lga} to {df.iloc[0].readiness:.1f} in {df.iloc[0].lga}. "
           f"{pct(bands.get('Critical',0)/bands.sum())} of visits score Critical (<50) and {pct(bands.get('Strong',0)/bands.sum())} Strong (>80).")
    return Result("Q25", "Readiness", "Which LGAs are most and least ready to deliver services?", sql, df, ans, fig, key_numbers={"mean_readiness": float(df.readiness.mean())})
r = q25(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
lga readiness open_rate attendance stock_availability vaccine_availability sessions
0 Sanga 84.929 0.938 0.778 0.817 0.809 0.862
1 Birnin Gwari 78.531 0.771 0.743 0.777 0.884 0.824
2 Ikara 77.146 0.854 0.696 0.811 0.758 0.805
3 Makarfi 74.106 0.833 0.694 0.742 0.689 0.795
4 Kaduna South 73.794 0.917 0.679 0.664 0.658 0.831
5 Kaura 73.673 0.854 0.719 0.713 0.684 0.810
6 Kagarko 73.119 0.812 0.731 0.663 0.738 0.798
7 Soba 72.013 0.771 0.704 0.719 0.664 0.788
8 Jema'a 71.277 0.729 0.718 0.636 0.674 0.804
9 Sabon Gari 70.856 0.750 0.790 0.745 0.705 0.791
10 Jaba 70.458 0.896 0.644 0.726 0.633 0.810
11 Kubau 70.127 0.812 0.639 0.661 0.642 0.772
12 Kachia 70.077 0.729 0.674 0.712 0.612 0.738
13 Kaduna North 69.694 0.792 0.738 0.692 0.636 0.800
14 Zaria 69.506 0.833 0.729 0.658 0.510 0.831
No description has been provided for this image
Average readiness is 70.7/100, from 62.0 in Igabi to 84.9 in Sanga. 7.5% of visits score Critical (<50) and 28.6% Strong (>80).

Q26. How does readiness differ by level of care?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q26(con):
    sql = """
    SELECT facility_type, AVG(readiness_score) AS readiness, AVG(attendance_rate) AS attendance, 1-AVG(stockout_rate) AS stock_availability,
           AVG(session_completion_rate) AS sessions, AVG(cce_functionality_rate) AS cold_chain, AVG(facility_open_on_arrival_flag) AS open_rate
    FROM v_visits GROUP BY 1"""
    df = q(con, sql)
    long = df.melt(id_vars=["facility_type", "readiness"], var_name="pillar", value_name="rate")
    fig = px.bar(long, x="pillar", y="rate", color="facility_type", barmode="group", text="rate", title="Readiness pillars by facility type",
                 category_orders={"facility_type": C.FACILITY_TYPES}, color_discrete_sequence=T.SERIES)
    fig.update_traces(texttemplate="%{text:.0%}", textposition="outside", width=0.18)
    fig.update_layout(yaxis_tickformat=".0%", xaxis_title="", yaxis_title="", legend_title="", yaxis_range=[0, 1.05])
    d = df.set_index("facility_type")
    ans = (f"General Hospitals score {d.loc['General Hospital','readiness']:.1f} and Health Posts {d.loc['Health Post','readiness']:.1f}. "
           f"Health Posts lag most on cold chain ({pct(d.loc['Health Post','cold_chain'])}) and being open on arrival ({pct(d.loc['Health Post','open_rate'])}), which argues for a differentiated support package by level of care.")
    return Result("Q26", "Readiness", "How does readiness differ by level of care?", sql, df, ans, fig)
r = q26(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
facility_type readiness attendance stock_availability sessions cold_chain open_rate
0 Basic Health Centre 68.322 0.667 0.670 0.764 0.763 0.785
1 General Hospital 75.932 0.737 0.709 0.810 0.861 0.778
2 Health Post 63.170 0.639 0.661 0.748 0.758 0.770
3 Primary Health Centre 73.939 0.710 0.701 0.794 0.800 0.827
No description has been provided for this image
General Hospitals score 75.9 and Health Posts 63.2. Health Posts lag most on cold chain (75.8%) and being open on arrival (77.0%), which argues for a differentiated support package by level of care.

Q27. Which individual facilities need urgent support?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q27(con):
    sql = """
    SELECT f.facility_id, f.facility_name, f.lga, f.facility_type, AVG(v.readiness_score) AS readiness, AVG(v.stockout_rate) AS stockout_rate,
           AVG(v.attendance_rate) AS attendance, AVG(v.facility_open_on_arrival_flag) AS open_rate, COUNT(*) AS visits
    FROM facility_visits v JOIN facilities f USING(facility_id) GROUP BY 1,2,3,4 ORDER BY readiness"""
    df = q(con, sql)
    bottom = df.head(15)
    fig = hbar(bottom, "readiness", "facility_name", "15 lowest-readiness facilities (mean across rounds)", color="lga", xlabel="Readiness score", fmt=".1f")
    fig.update_xaxes(tickformat=""); fig.update_layout(legend_title="")
    ans = (f"The 15 lowest-scoring facilities average {bottom.readiness.mean():.1f}/100, with attendance of {pct(bottom.attendance.mean())} and a {pct(bottom.stockout_rate.mean())} stock-out rate. "
           f"{bottom.lga.value_counts().index[0]} contributes the most facilities to this list. These are the candidates for an intensive support visit this quarter.")
    return Result("Q27", "Readiness", "Which individual facilities need urgent support?", sql, df, ans, fig)
r = q27(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
facility_id facility_name lga facility_type readiness stockout_rate attendance open_rate visits
0 KD-024 Tudun Madaki PHC Giwa Primary Health Centre 48.383 0.457 0.601 0.500 6
1 KD-164 Rafin Dan Gama HP Soba Health Post 48.383 0.574 0.500 0.500 6
2 KD-125 Kofar Gari HP Kudan Health Post 49.950 0.492 0.500 0.667 6
3 KD-109 Gidan Wada PHC Kauru Primary Health Centre 50.700 0.572 0.541 0.667 6
4 KD-027 Rafin Kaji HP Igabi Health Post 52.267 0.250 0.500 0.500 6
5 KD-128 Tudun Sarki HP Kudan Health Post 52.267 0.583 0.583 0.667 6
6 KD-172 Rigasa Fada HP Zangon Kataf Health Post 53.567 0.442 0.583 0.667 6
7 KD-032 Rigasa Sarki HP Igabi Health Post 53.633 0.426 0.553 0.833 6
8 KD-096 Sabon Dan Gama HP Kajuru Health Post 53.867 0.537 0.333 0.667 6
9 KD-053 Badarawa Kaji BHC Jema'a Basic Health Centre 53.967 0.594 0.604 0.167 6
10 KD-062 Narayi Rimi BHC Kachia Basic Health Centre 54.450 0.478 0.458 0.833 6
11 KD-133 Rigasa Fada HP Lere Health Post 54.483 0.540 0.417 1.000 6
12 KD-130 Rafin Wada PHC Lere Primary Health Centre 54.900 0.449 0.670 0.667 6
13 KD-126 Ungwan Abba GH Kudan General Hospital 55.217 0.638 0.675 0.833 6
14 KD-065 Gidan Sarki BHC Kaduna North Basic Health Centre 55.350 0.551 0.585 0.833 6
No description has been provided for this image
The 15 lowest-scoring facilities average 52.8/100, with attendance of 54.0% and a 50.6% stock-out rate. Kudan contributes the most facilities to this list. These are the candidates for an intensive support visit this quarter.

Q28. How are indicators trending across the bi-weekly rounds?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q28(con):
    sql = """
    SELECT round_name, round_number, AVG(readiness_score) AS readiness, AVG(stockout_rate) AS stockout_rate, AVG(vaccine_availability_rate) AS vaccine_availability,
           AVG(facility_open_on_arrival_flag) AS open_rate, AVG(cold_chain_interruption_since_last_visit_flag) AS cold_chain_interruption
    FROM v_visits GROUP BY 1,2 ORDER BY 2"""
    df = q(con, sql)
    fig = go.Figure()
    for col, name in [("stockout_rate", "Commodity stock-out rate"), ("vaccine_availability", "Vaccine availability"), ("open_rate", "Open on arrival"), ("cold_chain_interruption", "Cold chain interruption")]:
        fig.add_trace(go.Scatter(x=df.round_name, y=df[col], mode="lines+markers", name=name, line=dict(width=2), marker=dict(size=9)))
    fig.update_layout(title="Key indicators across visit rounds", yaxis_tickformat=".0%", xaxis_title="", yaxis_title="", hovermode="x unified")
    ans = (f"Commodity stock-outs rose from {pct(df.iloc[0].stockout_rate)} at baseline to {pct(df.iloc[-1].stockout_rate)} by {df.iloc[-1].round_name} as opening balances were drawn down faster than deliveries replaced them, "
           f"while vaccine availability held around {pct(df.vaccine_availability.mean())}. The trend, not the level, is the alarm: resupply cadence is not keeping pace with consumption.")
    return Result("Q28", "Readiness", "How are indicators trending across the bi-weekly rounds?", sql, df, ans, fig)
r = q28(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
round_name round_number readiness stockout_rate vaccine_availability open_rate cold_chain_interruption
0 Baseline 1 74.333 0.175 0.827 0.772 0.188
1 Bi-weekly 1 2 72.548 0.261 0.742 0.766 0.203
2 Bi-weekly 2 3 70.899 0.337 0.680 0.832 0.239
3 Bi-weekly 3 4 65.310 0.352 0.645 0.701 0.203
4 Bi-weekly 4 5 70.804 0.372 0.626 0.875 0.210
5 Bi-weekly 5 6 70.177 0.387 0.631 0.859 0.196
No description has been provided for this image
Commodity stock-outs rose from 17.5% at baseline to 38.7% by Bi-weekly 5 as opening balances were drawn down faster than deliveries replaced them, while vaccine availability held around 69.2%. The trend, not the level, is the alarm: resupply cadence is not keeping pace with consumption.

Q29. Which factors move together, and what are the strongest levers on readiness?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q29(con):
    sql = """
    SELECT readiness_score, attendance_rate, stockout_rate, vaccine_availability_rate, session_completion_rate, cce_functionality_rate,
           salary_paid_on_time_last_3_months_flag, requisition_submitted_last_cycle_flag, roster_updated_this_week_flag,
           distance_to_lga_hq_km, total_health_workers, security_incident_reported_flag, facility_open_on_arrival_flag
    FROM v_visits"""
    df = q(con, sql)
    corr = df.corr(numeric_only=True)
    labels = {"readiness_score": "Readiness", "attendance_rate": "Attendance", "stockout_rate": "Stock-out rate", "vaccine_availability_rate": "Vaccine availability",
              "session_completion_rate": "Session completion", "cce_functionality_rate": "Cold chain functional", "salary_paid_on_time_last_3_months_flag": "Salary on time",
              "requisition_submitted_last_cycle_flag": "Requisition submitted", "roster_updated_this_week_flag": "Roster updated", "distance_to_lga_hq_km": "Distance to LGA HQ",
              "total_health_workers": "Health workers", "security_incident_reported_flag": "Security incident", "facility_open_on_arrival_flag": "Open on arrival"}
    corr = corr.rename(index=labels, columns=labels)
    fig = go.Figure(go.Heatmap(z=corr.values, x=corr.columns, y=corr.index, zmin=-1, zmax=1, colorscale=[[0, T.DIVERGING[0]], [0.5, T.DIVERGING[2]], [1, T.DIVERGING[4]]],
                               text=np.round(corr.values, 2), texttemplate="%{text}", hovertemplate="%{y} vs %{x}: %{z:.2f}<extra></extra>", xgap=2, ygap=2))
    fig.update_layout(title="Correlation between readiness drivers", height=560, xaxis=dict(tickangle=-40), yaxis=dict(autorange="reversed"))
    s = corr["Readiness"].drop("Readiness").sort_values()
    ans = (f"Readiness correlates most positively with {s.index[-1]} (r={s.iloc[-1]:.2f}) and {s.index[-2]} (r={s.iloc[-2]:.2f}), and most negatively with {s.index[0]} (r={s.iloc[0]:.2f}). "
           f"Salary timeliness (r={corr.loc['Readiness','Salary on time']:.2f}) and requisition submission (r={corr.loc['Readiness','Requisition submitted']:.2f}) are the strongest management levers.")
    return Result("Q29", "Readiness", "Which factors move together, and what are the strongest levers on readiness?", sql, corr.reset_index(), ans, fig)
r = q29(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
index Readiness Attendance Stock-out rate Vaccine availability Session completion Cold chain functional Salary on time Requisition submitted Roster updated Distance to LGA HQ Health workers Security incident Open on arrival
0 Readiness 1.000 0.539 -0.418 0.542 0.494 0.414 0.235 0.184 0.173 -0.005 0.295 -0.299 0.572
1 Attendance 0.539 1.000 -0.140 0.213 0.353 0.147 0.335 0.053 0.270 -0.127 0.159 -0.296 0.129
2 Stock-out rate -0.418 -0.140 1.000 -0.282 -0.110 -0.073 -0.052 -0.452 -0.129 -0.066 -0.121 0.020 -0.006
3 Vaccine availability 0.542 0.213 -0.282 1.000 0.201 0.195 0.142 0.060 0.131 0.114 0.362 -0.057 0.070
4 Session completion 0.494 0.353 -0.110 0.201 1.000 0.131 0.310 0.030 0.064 -0.023 0.127 -0.429 0.126
5 Cold chain functional 0.414 0.147 -0.073 0.195 0.131 1.000 0.049 0.055 0.091 0.048 0.194 -0.003 0.005
6 Salary on time 0.235 0.335 -0.052 0.142 0.310 0.049 1.000 -0.031 0.027 0.011 0.034 -0.029 0.063
7 Requisition submitted 0.184 0.053 -0.452 0.060 0.030 0.055 -0.031 1.000 0.114 0.119 0.095 0.028 0.041
8 Roster updated 0.173 0.270 -0.129 0.131 0.064 0.091 0.027 0.114 1.000 0.027 0.151 -0.019 -0.024
9 Distance to LGA HQ -0.005 -0.127 -0.066 0.114 -0.023 0.048 0.011 0.119 0.027 1.000 0.004 0.024 0.008
10 Health workers 0.295 0.159 -0.121 0.362 0.127 0.194 0.034 0.095 0.151 0.004 1.000 -0.007 0.015
11 Security incident -0.299 -0.296 0.020 -0.057 -0.429 -0.003 -0.029 0.028 -0.019 0.024 -0.007 1.000 -0.187
12 Open on arrival 0.572 0.129 -0.006 0.070 0.126 0.005 0.063 0.041 -0.024 0.008 0.015 -0.187 1.000
No description has been provided for this image
Readiness correlates most positively with Open on arrival (r=0.57) and Vaccine availability (r=0.54), and most negatively with Stock-out rate (r=-0.42). Salary timeliness (r=0.24) and requisition submission (r=0.18) are the strongest management levers.

Q30. Where are the weakest facilities located?¶

The cell below defines the analysis for this question (SQL + figure), then runs it.

def q30(con):
    sql = """
    SELECT f.facility_name, f.lga, f.facility_type, f.latitude, f.longitude, AVG(v.readiness_score) AS readiness, AVG(v.stockout_rate) AS stockout_rate, AVG(v.attendance_rate) AS attendance
    FROM facility_visits v JOIN facilities f USING(facility_id) GROUP BY 1,2,3,4,5"""
    df = q(con, sql)
    fig = px.scatter_map(df, lat="latitude", lon="longitude", color="readiness", size="stockout_rate", size_max=16, hover_name="facility_name",
                         hover_data={"lga": True, "facility_type": True, "readiness": ":.1f", "stockout_rate": ":.1%", "attendance": ":.1%", "latitude": False, "longitude": False},
                         color_continuous_scale=T.SEQUENTIAL, zoom=6.6, center=dict(lat=10.35, lon=7.7), title="Facility readiness map (colour = readiness, size = stock-out rate)")
    fig.update_layout(map_style="carto-positron", height=620, margin=dict(l=0, r=0, t=56, b=0), coloraxis_colorbar=dict(title="Readiness"))
    lga = df.groupby("lga").readiness.mean().sort_values()
    sec = q(con, "SELECT security_risk_lga s, AVG(readiness_score) r FROM v_visits GROUP BY 1").set_index("s").r
    ans = (f"The lowest-readiness clusters are {', '.join(lga.index[:3])} (mean {lga.iloc[:3].mean():.1f}) while {', '.join(lga.index[-3:])} perform best (mean {lga.iloc[-3:].mean():.1f}). "
           f"Facilities in security-risk LGAs average {sec.get(1, np.nan):.1f} versus {sec.get(0, np.nan):.1f} elsewhere - geography and security explain a large share of the variation.")
    return Result("Q30", "Readiness", "Where are the weakest facilities located?", sql, df, ans, fig)
r = q30(con)
display(r.data.head(15))
r.figure.show()
print(r.answer)
results.append(r)
facility_name lga facility_type latitude longitude readiness stockout_rate attendance
0 Badarawa Dan Gama PHC Kauru Primary Health Centre 10.107 8.228 73.217 0.343 0.696
1 Badarawa Dan Gama PHC Sanga Primary Health Centre 9.431 8.472 88.117 0.174 0.852
2 Badarawa Fada BHC Igabi Basic Health Centre 10.751 7.660 64.367 0.471 0.563
3 Badarawa Fada PHC Birnin Gwari Primary Health Centre 10.665 6.582 85.783 0.181 0.883
4 Badarawa Kaji BHC Jema'a Basic Health Centre 9.354 8.436 53.967 0.594 0.604
5 Badarawa Kaji BHC Kagarko Basic Health Centre 9.502 7.798 80.100 0.457 0.853
6 Badarawa Kaji BHC Zangon Kataf Basic Health Centre 9.707 8.224 61.783 0.198 0.547
7 Badarawa Kaji PHC Chikun Primary Health Centre 10.253 7.306 56.300 0.519 0.728
8 Badarawa Kaji PHC Jaba Primary Health Centre 9.575 8.016 88.733 0.087 0.813
9 Badarawa Rimi HP Kudan Health Post 11.151 7.847 59.783 0.333 0.590
10 Dutse Abba PHC Kachia Primary Health Centre 9.759 8.029 71.733 0.406 0.778
11 Dutse Dan Gama BHC Jaba Basic Health Centre 9.409 8.005 78.050 0.500 0.813
12 Dutse Fada PHC Zaria Primary Health Centre 11.144 7.676 68.967 0.493 0.594
13 Dutse Kaji BHC Zaria Basic Health Centre 11.108 7.764 62.100 0.398 0.766
14 Dutse Kaji HP Kaduna South Health Post 10.339 7.349 65.433 0.138 0.658
No description has been provided for this image
The lowest-readiness clusters are Igabi, Kudan, Kajuru (mean 63.5) while Ikara, Birnin Gwari, Sanga perform best (mean 80.2). Facilities in security-risk LGAs average 67.8 versus 71.7 elsewhere - geography and security explain a large share of the variation.

Summary of answers¶

Every answer above, collected in one table (also written to outputs/analysis_results.json by the pipeline).

summary = pd.DataFrame([{"id": r.id, "section": r.section, "question": r.question, "answer": r.answer} for r in results])
pd.set_option("display.max_colwidth", None)
summary.style.set_properties(**{"text-align": "left", "white-space": "pre-wrap"}).hide(axis="index")
id section question answer
Q01 Facility access How often are facilities actually open when a data champion arrives, and where is access weakest? Facilities were open on arrival in 80.1% of 1104 visits. Where closed, data champions waited 46 minutes on average. Kauru is the weakest LGA (68.8%) and Sanga the strongest (93.8%).
Q02 Facility access What are the main reasons facilities are closed when visited? The leading cause of a closed facility is 'Staff not yet arrived' (30.0% of 220 cases). Insecurity accounts for 17.7%, concentrated in security-risk LGAs.
Q03 Facility access What are facility operating hours, and can facilities refer emergencies with transport? 41.8% of facilities report 24-hour operation, driven by PHCs and General Hospitals; most Health Posts run 6-8 hour days. 78.2% of visits confirmed the facility can refer obstetric emergencies, but transport was available when needed in only 59.1% of those.
Q04 Cold chain How functional is the cold chain, and how often is it interrupted? Where equipment exists, functionality ranges from 74.5% (Rush (3L)) to 82.2% (Refrigerators). Only 66.4% of facilities with cold chain equipment have at least one working vaccine refrigerator, and 20.7% of visits recorded a cold chain interruption since the previous visit, most often 'Power failure / no electricity'.
Q05 Service delivery Which services do facilities offer, and how reliably are planned sessions delivered? Malaria and ANC are near-universal, while Nutrition (69.6%) and Labour & Delivery are the least available. Planned sessions were fully delivered in 78.0% of service-weeks; Immunization has the lowest completion (75.4%).
Q06 Service delivery What stops planned sessions from happening? 'Staff unavailable (leave, redeployment, strike)' explains 32.6% of missed sessions - a human-resource problem before a supply problem. Security concerns account for 10.2% and vaccine or cold-chain issues for 4.6%.
Q07 Service delivery What is training coverage across the network? 77.2% of facilities had any staff trained in the past two years. Coverage is highest for Life Saving Skills (28.3%) and lowest for Nutrition / IYCF (18.5%); no single training reaches half of facilities.
Q08 Human resources What does the workforce look like by cadre and employment type? The network has 4,652 health workers: 65.4% permanent, 20.7% ad hoc/seconded and 13.8% volunteers. CHEWs and JCHEWs form the backbone; 38.1% of PHCs have no permanent doctor and 9.1% of PHCs/BHCs have no permanent nurse or midwife. Women make up 55.1% of the workforce.
Q09 Human resources How high is absenteeism, and which cadres are most affected? Across all visits, 72.0% of permanent staff scheduled for duty were actually present - an absenteeism rate of 28.0%. JCHEWs are least reliably present (68.6%); Pharmacy Technicians most (75.8%).
Q10 Human resources Where is absenteeism geographically concentrated? Attendance ranges from 57.6% in Igabi to 79.7% in Sanga - a 22-point spread that points to LGA-level management and security effects rather than individual behaviour.
Q11 Human resources Why are staff absent? 'Leave (Study, Annual, Maternity, etc.)' is the top recorded reason (11.2%), followed by 'Access to Salary' (9.0%). Salary-linked absence (going to access salary, or dissatisfaction with pay) accounts for 15.4% of all absences and is directly addressable by payment reform.
Q12 Human resources How do salary delays affect attendance and service delivery over time? In Bi-weekly 3 salary timeliness collapsed to 14.1% (vs 78.9% in other rounds); attendance fell to 63.7% (vs 73.7%) and session completion to 71.0% in the same round. Late salaries translate almost immediately into empty duty posts.
Q13 Human resources Do roster practices and salary timeliness independently predict attendance? When salary is on time and the roster was updated this week, attendance is 80.5%; with delayed salary and a stale roster it drops to 55.0%. Both levers matter, and roster discipline partly cushions the salary effect.
Q14 Human resources How much service time is lost to accessing salary payments? In rural facilities, staff leave the post to access salary two or more times a month in 49.1% of visits; the main reason is 'No bank or ATM nearby'. Bringing agent banking or POS access to facility catchments would recover many lost duty days.
Q15 Supply chain Which essential medicines and commodities are most often stocked out? On average 31.4% of stocked tracer items had a stock-out since the previous visit. Maternal life-saving commodities fare worst (mean 33.9%): Calibrated Blood Collection Drape (55.4%), Magnesium Sulphate Injection (42.7%) and Tranexamic Acid Injection (42.6%). Paracetamol Tablet is the most reliably available (22.7%).
Q16 Supply chain Are stock-outs caused upstream or at the facility? 58.3% of stock-outs are attributed to upstream causes (central stock-out, delivery delay, partial fulfilment, no feedback), versus 20.6% to facility-side causes (late requisition, poor forecasting, unreplaced expiries). The single largest is 'Stock-out at LGA/state/central supply level' at 31.0%.
Q17 Supply chain Which LGAs have the worst commodity availability? Stock-out rates range from 18.1% in Sanga to 42.2% in Kauru. The top-five LGAs (Kauru, Chikun, Kudan, Igabi, Giwa) should be prioritised for supply-chain supervision.
Q18 Supply chain How well does the requisition-to-delivery process work? Only 61.7% of facilities submitted a requisition in the last cycle; of those, 71.4% were complete and on time and 48.9% were fully filled. Delivery documentation accompanied 79.4% of receipts. Each leak in this funnel compounds into the stock-out rates above.
Q19 Supply chain Do requisition discipline and stock-management skills reduce stock-outs? Facilities that submitted a requisition and know how to calculate minimum stock have a 23.6% stock-out rate, against 43.5% where no requisition was submitted and nobody can calculate minimum stock. Requisition discipline and LMIS skills are the cheapest stock-out reducers available.
Q20 Supply chain Which supply sources are most reliable? KDHSMA supplies 36.6% of stocked items. Items sourced from Open Market show the lowest stock-out rate (24.6%) and NGO-sourced items the highest (36.5%).
Q21 Supply chain How many facilities are below minimum stock (early-warning)? A minimum stock level was known for 78.4% of stocked items. Where known, 85.2% of items sit below the minimum - the facility is one bad fortnight away from a stock-out. Calibrated Blood Collection Drape (95.8%) is most exposed.
Q22 Vaccines Which vaccines are available, and where are the gaps? 64.7% of facilities stock vaccines on site, 25.0% immunise without stocking and 10.3% offer no immunisation. Among stocking facilities, availability is lowest for HPV (55.6%) and MEN A (58.7%) - the newer antigens - and highest for OPV (78.0%).
Q23 Vaccines How much does cold chain status drive vaccine availability and immunisation delivery? Vaccine availability is 72.7% with a working fridge and no interruption, but 58.2% with working fridge and interruption. Immunisation sessions were fully delivered in 81.5% of weeks where the fridge works versus 57.1% where it does not.
Q24 Vaccines Which vaccines are consumed most, and why? OPV, PCV and PENTA account for 49.4% of doses used. The most common driver of a high-dispensing week is 'Scheduled routine immunization session' (53.4%), and HPV demand is mostly school-based sessions.
Q25 Readiness Which LGAs are most and least ready to deliver services? Average readiness is 70.7/100, from 62.0 in Igabi to 84.9 in Sanga. 7.5% of visits score Critical (<50) and 28.6% Strong (>80).
Q26 Readiness How does readiness differ by level of care? General Hospitals score 75.9 and Health Posts 63.2. Health Posts lag most on cold chain (75.8%) and being open on arrival (77.0%), which argues for a differentiated support package by level of care.
Q27 Readiness Which individual facilities need urgent support? The 15 lowest-scoring facilities average 52.8/100, with attendance of 54.0% and a 50.6% stock-out rate. Kudan contributes the most facilities to this list. These are the candidates for an intensive support visit this quarter.
Q28 Readiness How are indicators trending across the bi-weekly rounds? Commodity stock-outs rose from 17.5% at baseline to 38.7% by Bi-weekly 5 as opening balances were drawn down faster than deliveries replaced them, while vaccine availability held around 69.2%. The trend, not the level, is the alarm: resupply cadence is not keeping pace with consumption.
Q29 Readiness Which factors move together, and what are the strongest levers on readiness? Readiness correlates most positively with Open on arrival (r=0.57) and Vaccine availability (r=0.54), and most negatively with Stock-out rate (r=-0.42). Salary timeliness (r=0.24) and requisition submission (r=0.18) are the strongest management levers.
Q30 Readiness Where are the weakest facilities located? The lowest-readiness clusters are Igabi, Kudan, Kajuru (mean 63.5) while Ikara, Birnin Gwari, Sanga perform best (mean 80.2). Facilities in security-risk LGAs average 67.8 versus 71.7 elsewhere - geography and security explain a large share of the variation.

Predictive analytics¶

Three forward-looking models plus a segmentation, trained here on the warehouse exactly as the pipeline does (src/ml/predict.py). Every model uses what was observed at visit t to say something about visit t+1, so they can be re-run after each bi-weekly round to steer the next one.

Model Question it answers Validation
Stock-out risk will this commodity be stocked out at the next visit? train rounds 1–4→2–5, test round 5→6
At-risk facility will readiness fall below 65 at the next visit? same time split
Attendance drivers what attendance should we expect under these salary / roster / security conditions? GroupKFold on unseen facilities
Segmentation which facilities share a profile across the readiness pillars? silhouette
from ml import predict as ML

visits, com = ML.load_frames(ROOT / "outputs" / "ls2_survey.db")
ML.ML_DIR.mkdir(parents=True, exist_ok=True)
print(f"{len(visits):,} visits, {len(com):,} stocked commodity observations")
1,104 visits, 23,892 stocked commodity observations

Stock-out risk at the next visit¶

Per facility × commodity: current balance and adequacy vs the minimum stock level, quantity received, requisition behaviour, supplier, knowledge of minimum-stock calculation, facility context and whether the item was already stocked out. Three candidate classifiers are compared on the held-out round and the best one is kept.

so_meta, so_pred, so_figs = ML.stockout_model(visits, com)
print("best model:", so_meta["best_model"], "| positive rate in test round:", f"{so_meta['positive_rate_test']:.1%}",
      "| recommended threshold:", f"{so_meta['recommended_threshold']:.2f}")
display(pd.DataFrame(so_meta["candidates"]).T.round(3))
so_figs["roc"].show()
so_figs["importance"].show()
best model: random_forest | positive rate in test round: 38.5% | recommended threshold: 0.32
roc_auc avg_precision
logistic_regression 0.844 0.698
random_forest 0.859 0.728
gradient_boosting 0.855 0.721
No description has been provided for this image
No description has been provided for this image

Emergency-resupply list: highest stock-out risk for the next visit¶

top = so_pred.sort_values("stockout_risk", ascending=False).head(20)
top.assign(stockout_risk=top.stockout_risk.map("{:.0%}".format)).reset_index(drop=True)
facility_id lga facility_type commodity category stock_balance minimum_stock_level supplier stockout_flag stockout_risk
0 KD-066 Kaduna North Basic Health Centre Calibrated Blood Collection Drape Maternal 0.000 NaN KDHSMA 1.000 84%
1 KD-172 Zangon Kataf Health Post Calibrated Blood Collection Drape Maternal 0.000 NaN KDHSMA 1.000 83%
2 KD-064 Kachia Health Post Ibuprofen Suspension Child 0.000 18.000 KDHSMA 1.000 83%
3 KD-064 Kachia Health Post Ferrous Sulphate Maternal 0.000 448.000 KDHSMA 1.000 83%
4 KD-064 Kachia Health Post Zinc Child 0.000 139.000 KDHSMA 1.000 82%
5 KD-064 Kachia Health Post Misoprostol Maternal 0.000 51.000 KDHSMA 1.000 81%
6 KD-064 Kachia Health Post Magnesium Sulphate Injection Maternal 0.000 9.000 Free MNCH 1.000 81%
7 KD-053 Jema'a Basic Health Centre Calibrated Blood Collection Drape Maternal 0.000 NaN KDHSMA 1.000 81%
8 KD-026 Igabi Basic Health Centre Magnesium Sulphate Injection Maternal 0.000 NaN Free MNCH 1.000 81%
9 KD-086 Kagarko Basic Health Centre Calibrated Blood Collection Drape Maternal 0.000 NaN KDHSMA 1.000 81%
10 KD-032 Igabi Health Post Artemether Injection General 0.000 15.000 LGA 1.000 81%
11 KD-016 Chikun General Hospital Calibrated Blood Collection Drape Maternal 0.000 129.000 KDHSMA 1.000 80%
12 KD-133 Lere Health Post Calibrated Blood Collection Drape Maternal 0.000 10.000 LGA 1.000 80%
13 KD-126 Kudan General Hospital Calibrated Blood Collection Drape Maternal 0.000 NaN KDHSMA 1.000 80%
14 KD-064 Kachia Health Post IV Fluids Maternal 0.000 24.000 KDHSMA 1.000 80%
15 KD-064 Kachia Health Post Giving Sets Maternal 0.000 25.000 KDHSMA 1.000 80%
16 KD-032 Igabi Health Post Giving Sets Maternal 0.000 23.000 KDHSMA 1.000 80%
17 KD-172 Zangon Kataf Health Post Magnesium Sulphate Injection Maternal 0.000 NaN Free MNCH 1.000 80%
18 KD-066 Kaduna North Basic Health Centre Magnesium Sulphate Injection Maternal 0.000 NaN KDHSMA 1.000 80%
19 KD-064 Kachia Health Post Injectable Contraceptives Family Planning 0.000 28.000 KDHSMA 1.000 80%

Facilities at risk of dropping below readiness 65¶

A random forest over the current readiness pillars, management practices (salary, roster, requisition), cold chain and security context flags facilities likely to be in the Weak/Critical bands at the next visit, so supportive supervision can be scheduled before the drop.

risk_meta, risk_pred, risk_figs = ML.at_risk_model(visits)
print({k: round(v, 3) for k, v in risk_meta["metrics"].items()})
risk_figs["roc"].show()
risk_figs["importance"].show()
top_risk = (risk_pred.merge(visits[["facility_id", "facility_name", "lga", "facility_type"]].drop_duplicates(), on="facility_id")
            .sort_values("at_risk_probability", ascending=False).head(20))
top_risk.assign(at_risk_probability=top_risk.at_risk_probability.map("{:.0%}".format)).reset_index(drop=True)
{'roc_auc': 0.865, 'avg_precision': 0.783, 'positive_rate_test': 0.321}
No description has been provided for this image
No description has been provided for this image
facility_id readiness_score at_risk_probability facility_name lga facility_type
0 KD-109 52.600 89% Gidan Wada PHC Kauru Primary Health Centre
1 KD-126 59.100 89% Ungwan Abba GH Kudan General Hospital
2 KD-062 58.600 89% Narayi Rimi BHC Kachia Basic Health Centre
3 KD-112 57.800 89% Kofar Abba BHC Kauru Basic Health Centre
4 KD-172 35.200 89% Rigasa Fada HP Zangon Kataf Health Post
5 KD-182 56.300 89% Maraban Wada HP Zaria Health Post
6 KD-164 48.400 88% Rafin Dan Gama HP Soba Health Post
7 KD-027 60.800 88% Rafin Kaji HP Igabi Health Post
8 KD-133 50.400 88% Rigasa Fada HP Lere Health Post
9 KD-093 58.300 88% Dutse Kaji HP Kajuru Health Post
10 KD-020 52.500 88% Gwaraji Kaji PHC Giwa Primary Health Centre
11 KD-032 38.900 87% Rigasa Sarki HP Igabi Health Post
12 KD-017 60.900 87% Zango Gari PHC Giwa Primary Health Centre
13 KD-024 28.400 86% Tudun Madaki PHC Giwa Primary Health Centre
14 KD-125 44.500 86% Kofar Gari HP Kudan Health Post
15 KD-065 64.300 86% Gidan Sarki BHC Kaduna North Basic Health Centre
16 KD-089 63.300 85% Gidan Kanawa BHC Kajuru Basic Health Centre
17 KD-085 57.600 85% Ungwan Kaji PHC Kagarko Primary Health Centre
18 KD-167 60.700 85% Gwaraji Rimi BHC Soba Basic Health Centre
19 KD-010 47.500 84% Narayi Wada GH Chikun General Hospital

What drives permanent-staff attendance?¶

Next-round attendance is dominated by small-headcount noise (a facility with 8 scheduled staff swings ±12 points by chance), so instead of forecasting it the model explains the attendance to expect under the conditions observed at a visit, validated on facilities it has never seen. That makes it a what-if tool: the counterfactual below re-scores every facility as if salaries had been paid on time.

att_meta, att_pred, att_figs = ML.attendance_model(visits)
print({k: round(v, 3) for k, v in att_meta["metrics"].items()})
att_figs["scatter"].show()
att_figs["importance"].show()
gain = (att_pred.assign(gain=lambda d: d.expected_attendance_if_salary_on_time - d.expected_attendance)
        .merge(visits[["facility_id", "facility_name", "lga"]].drop_duplicates(), on="facility_id")
        .query("gain > 0.005").sort_values("gain", ascending=False))
print(f"{len(gain)} facilities with late salary at the latest visit; mean expected attendance gain if paid on time: {gain.gain.mean():+.1%}")
top_gain = gain.head(20).assign(facility=lambda d: d.facility_name + " (" + d.lga + ")")
hbar(top_gain, "gain", "facility", "Expected attendance gain if salary is paid on time (top 20)", xlabel="Attendance points", fmt=".1%").show()
{'grouped_cv_r2': 0.264, 'grouped_cv_r2_headcount_weighted': 0.405, 'grouped_cv_mae': 0.139, 'naive_global_mean_mae': 0.169, 'mean_gain_salary_on_time': 0.036, 'mean_gain_roster_updated': 0.041}
No description has been provided for this image
No description has been provided for this image
48 facilities with late salary at the latest visit; mean expected attendance gain if paid on time: +13.5%
No description has been provided for this image

Facility segments¶

K-means on each facility's mean profile across the readiness pillars. The silhouette is modest, so the segments should guide the support package (light-touch monitoring / supply-side fixes / full supportive supervision) rather than dictate it.

seg_meta, seg_df, seg_fig = ML.segmentation(visits)
print("k =", seg_meta["k"], "| silhouette =", round(seg_meta["silhouette"], 3), "|", seg_meta["segment_sizes"])
seg_fig.show()
pd.DataFrame(seg_meta["centres"]).set_index("segment_id").T.round(2)
k = 3 | silhouette = 0.18 | {'High performers': 71, 'Multi-constraint / priority': 69, 'Stock-constrained': 44}
No description has been provided for this image
segment_id High performers Stock-constrained Multi-constraint / priority
facility_open_on_arrival_flag 0.840 0.840 0.730
attendance_rate 0.760 0.720 0.600
stockout_rate 0.190 0.460 0.350
vaccine_availability_rate 0.760 0.720 0.610
session_completion_rate 0.830 0.790 0.720
cce_functionality_rate 0.830 0.810 0.740
salary_paid_on_time_last_3_months_flag 0.710 0.780 0.590
requisition_submitted_last_cycle_flag 0.830 0.240 0.640

Facility score card¶

All model outputs for the latest round joined into one table — the same table the dashboards' Predictions tab shows.

scores = (visits[visits.round_number == ML.LAST_ROUND][["facility_id", "facility_name", "lga", "facility_type", "readiness_score", "attendance_rate", "stockout_rate"]]
          .merge(risk_pred.drop(columns=["readiness_score"]), on="facility_id")
          .merge(att_pred.drop(columns=["attendance_rate"]), on="facility_id")
          .merge(seg_df, on="facility_id")
          .merge(so_pred.groupby("facility_id").stockout_risk.mean().rename("mean_stockout_risk").reset_index(), on="facility_id")
          .sort_values("at_risk_probability", ascending=False))
scores.head(25).style.format({c: "{:.0%}" for c in ["attendance_rate", "stockout_rate", "at_risk_probability", "expected_attendance", "expected_attendance_if_salary_on_time", "expected_attendance_if_roster_updated", "mean_stockout_risk"]}).format({"readiness_score": "{:.1f}"}).hide(axis="index")
facility_id facility_name lga facility_type readiness_score attendance_rate stockout_rate at_risk_probability expected_attendance expected_attendance_if_salary_on_time expected_attendance_if_roster_updated segment mean_stockout_risk
KD-109 Gidan Wada PHC Kauru Primary Health Centre 52.6 0.500000 0.608696 0.894348 0.544642 0.705511 0.600646 Multi-constraint / priority 0.638334
KD-126 Ungwan Abba GH Kudan General Hospital 59.1 0.803922 0.782609 0.891191 0.748568 0.751825 0.748568 Stock-constrained 0.635633
KD-062 Narayi Rimi BHC Kachia Basic Health Centre 58.6 0.400000 0.434783 0.890739 0.713255 0.713255 0.713255 Multi-constraint / priority 0.456375
KD-112 Kofar Abba BHC Kauru Basic Health Centre 57.8 0.600000 0.826087 0.887360 0.774962 0.774962 0.774962 Stock-constrained 0.652038
KD-172 Rigasa Fada HP Zangon Kataf Health Post 35.2 0.500000 0.652174 0.886756 0.679620 0.682877 0.770710 Stock-constrained 0.633534
KD-182 Maraban Wada HP Zaria Health Post 56.3 0.500000 0.611111 0.886028 0.656238 0.781838 0.729344 Stock-constrained 0.529617
KD-164 Rafin Dan Gama HP Soba Health Post 48.4 0.500000 1.000000 0.882685 0.617436 0.617436 0.721046 Multi-constraint / priority 0.635766
KD-027 Rafin Kaji HP Igabi Health Post 60.8 0.500000 0.375000 0.880637 0.664988 0.664988 0.664988 Multi-constraint / priority 0.362280
KD-133 Rigasa Fada HP Lere Health Post 50.4 0.000000 0.666667 0.878936 0.655882 0.791455 0.655882 Multi-constraint / priority 0.690031
KD-020 Gwaraji Kaji PHC Giwa Primary Health Centre 52.5 0.600000 0.391304 0.875232 0.650824 0.778103 0.650824 Multi-constraint / priority 0.501403
KD-032 Rigasa Sarki HP Igabi Health Post 38.9 0.000000 0.555556 0.872692 0.161516 0.161516 0.325493 Stock-constrained 0.639367
KD-017 Zango Gari PHC Giwa Primary Health Centre 60.9 0.437500 0.476190 0.870320 0.635953 0.635953 0.726138 Multi-constraint / priority 0.483248
KD-024 Tudun Madaki PHC Giwa Primary Health Centre 28.4 0.461538 0.652174 0.860130 0.520692 0.523948 0.520692 Multi-constraint / priority 0.595057
KD-125 Kofar Gari HP Kudan Health Post 44.5 1.000000 0.619048 0.857506 0.664350 0.684476 0.778859 Multi-constraint / priority 0.563579
KD-065 Gidan Sarki BHC Kaduna North Basic Health Centre 64.3 0.875000 0.565217 0.856914 0.588234 0.728310 0.645254 Stock-constrained 0.562968
KD-089 Gidan Kanawa BHC Kajuru Basic Health Centre 63.3 1.000000 0.391304 0.852614 0.658771 0.658771 0.749511 Multi-constraint / priority 0.399512
KD-085 Ungwan Kaji PHC Kagarko Primary Health Centre 57.6 0.615385 0.478261 0.849287 0.700909 0.833297 0.700909 High performers 0.424545
KD-167 Gwaraji Rimi BHC Soba Basic Health Centre 60.7 0.714286 0.478261 0.847451 0.772825 0.772825 0.772825 Stock-constrained 0.444172
KD-010 Narayi Wada GH Chikun General Hospital 47.5 0.595238 0.434783 0.843355 0.562175 0.568679 0.562175 Multi-constraint / priority 0.437574
KD-151 Sabon Wada HP Sabon Gari Health Post 42.1 0.333333 0.500000 0.841149 0.641549 0.641549 0.745159 Stock-constrained 0.572768
KD-128 Tudun Sarki HP Kudan Health Post 65.7 1.000000 0.750000 0.836044 0.574137 0.688741 0.574137 Multi-constraint / priority 0.480509
KD-130 Rafin Wada PHC Lere Primary Health Centre 64.2 0.823529 0.521739 0.831033 0.737462 0.737462 0.820759 Multi-constraint / priority 0.497779
KD-072 Rigasa Sarki GH Kaduna North General Hospital 60.2 0.783333 0.739130 0.827433 0.783614 0.783614 0.852650 Stock-constrained 0.573451
KD-139 Zango Wada HP Makarfi Health Post 62.4 1.000000 0.521739 0.823763 0.567350 0.681722 0.646908 Multi-constraint / priority 0.496078
KD-103 Rafin Wada BHC Kaura Basic Health Centre 54.6 0.666667 0.565217 0.817181 0.808484 0.808484 0.808484 Multi-constraint / priority 0.502705

Dashboard¶

The same metrics as a filterable dashboard. dashboard() below is built on src/dashboard/charts.py — the module behind the Dash and Streamlit editions — so what you see here is what the hosted apps show. Call it with any combination of filters (lgas, types, setting, security, rounds, readiness) and it re-renders the KPI tiles and every chart for that slice.

Hosted editions of the full dashboard (8 tabs, sliders, tables, CSV export):

Edition Where
Static https://chidex-coder.github.io/ls2-facility-survey-analytics/
Streamlit streamlit run streamlit_app.py (or Streamlit Community Cloud)
Dash python src/dashboard/dash_app.py (or the Hugging Face Space / Render service)
from dashboard import charts as CH
from IPython.display import HTML, IFrame, display

DATA = CH.load_data()

def dashboard(lgas=None, types=None, setting=None, security=None, rounds=None, readiness=(0, 100), tabs=("overview", "hrh", "supply", "vaccines")):
    rounds = rounds or (1, DATA["n_rounds"])
    F = CH.filtered(DATA, lgas, types, setting, security, rounds, readiness)
    print(CH.summary_text(F, lgas, types, rounds))
    o = CH.overview(F)
    tiles = "".join(f"<div style='flex:1;min-width:150px;border:1px solid #e3e2dd;border-radius:12px;padding:10px 14px'>"
                    f"<div style='font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#8a8985;font-weight:600'>{l}</div>"
                    f"<div style='font-size:24px;font-weight:700;color:{ {'good':'#0ca30c','warn':'#ec835a','bad':'#d03b3b'}.get(t,'#0b0b0b') }'>{v}</div>"
                    f"<div style='font-size:11px;color:#52514e'>{s}</div></div>" for l, v, s, t in o["kpis"])
    display(HTML(f"<div style='display:flex;flex-wrap:wrap;gap:10px;font-family:Inter,sans-serif'>{tiles}</div>"))
    figs = {"overview": {k: o[k] for k in ("trend", "lga", "map", "pillars")}, "access": CH.access(F), "hrh": CH.hrh(F), "supply": CH.supply(F), "vaccines": CH.vaccines(F)}
    for t in tabs:
        display(HTML(f"<h3 style='font-family:Inter,sans-serif;margin:18px 0 4px'>{TAB_NAMES[t]}</h3>"))
        for key, fig in figs[t].items():
            fig.update_layout(title=TITLES.get((t, key), key), margin=dict(t=90)).show()
    return F

TAB_NAMES = {"overview": "Overview", "access": "Access & readiness", "hrh": "Human resources", "supply": "Supply chain", "vaccines": "Vaccines"}
TITLES = {("overview", "trend"): "Readiness score trend by round", ("overview", "lga"): "Readiness by LGA", ("overview", "map"): "Facility map (colour = readiness, size = stock-out rate)", ("overview", "pillars"): "Readiness pillars by facility type",
          ("access", "open_lga"): "Open on arrival, by LGA", ("access", "closed"): "Why facilities were closed on arrival", ("access", "hours"): "Hours of operation by facility type", ("access", "referral"): "Emergency referral capacity", ("access", "cce"): "Cold chain equipment: availability vs functionality", ("access", "services"): "Service availability & session completion", ("access", "session_reasons"): "Why planned sessions were missed", ("access", "training"): "Training coverage (past 2 years)",
          ("hrh", "att_cadre"): "Attendance of scheduled permanent staff, by cadre", ("hrh", "att_lga"): "Attendance by LGA", ("hrh", "absence"): "Why permanent staff were absent", ("hrh", "workforce"): "Workforce composition by cadre", ("hrh", "salary_trend"): "Salary timeliness & attendance by round", ("hrh", "salary_roster"): "Attendance by salary timeliness and roster practice", ("hrh", "leave"): "How often staff leave the post to access salary", ("hrh", "distance"): "Attendance vs distance to LGA headquarters",
          ("supply", "stockout_com"): "Stock-out rate by commodity", ("supply", "heat"): "Stock-out heatmap: LGA × commodity", ("supply", "reasons"): "Main reasons for stock-outs", ("supply", "funnel"): "Requisition funnel", ("supply", "supplier"): "Stock-out rate by supplier", ("supply", "req"): "Requisition behaviour vs stock-outs", ("supply", "below_min"): "Below minimum stock (early warning)", ("supply", "trend"): "Stock-out trend by category and round",
          ("vaccines", "avail"): "Vaccine availability (stocking facilities)", ("vaccines", "doses"): "Doses used, by vaccine", ("vaccines", "status"): "Vaccine stocking status", ("vaccines", "cce"): "Vaccine availability by cold chain status", ("vaccines", "cc_reasons"): "Cold chain interruption reasons", ("vaccines", "heat"): "Vaccine availability by LGA and round"}

Whole network, all rounds¶

F = dashboard(tabs=("overview", "supply"))
Showing 1,104 visits to 184 facilities · rounds 1–6 (Baseline → Bi-weekly 5) · all 23 LGA(s) · all facility types
Visits
1,104
184 facilities
Mean readiness
70.7
composite score /100
Open on arrival
80.1%
share of visits
Staff attendance
72.0%
permanent present ÷ scheduled
Stock-out rate
31.4%
tracer items stocked out
Vaccine availability
69.2%
antigens in stock
Sessions completed
78.0%
planned sessions delivered
Cold chain interruptions
20.7%
facilities with CCE
Salary on time
68.1%
last 3 months

Overview

No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

Supply chain

No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

A slice: security-risk LGAs, Primary Health Centres, rounds 3–6¶

Change the arguments and re-run to explore any other cut.

F = dashboard(types=["Primary Health Centre"], security="1", rounds=(3, 6), tabs=("overview", "hrh"))
Showing 100 visits to 25 facilities · rounds 3–6 (Bi-weekly 2 → Bi-weekly 5) · all 23 LGA(s) · Primary Health Centre
Visits
100
25 facilities
Mean readiness
68.3
composite score /100
Open on arrival
78.0%
share of visits
Staff attendance
64.7%
permanent present ÷ scheduled
Stock-out rate
36.9%
tracer items stocked out
Vaccine availability
66.7%
antigens in stock
Sessions completed
73.4%
planned sessions delivered
Cold chain interruptions
17.5%
facilities with CCE
Salary on time
63.0%
last 3 months

Overview

No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

Human resources

No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

Live filters (when run in Jupyter)¶

With ipywidgets installed this cell gives dropdowns and sliders; the outputs are not stored in the saved notebook.

try:
    import ipywidgets as W
    W.interact(lambda lga, ftype, rounds, tab: dashboard(lgas=[lga] if lga != "All" else None, types=[ftype] if ftype != "All" else None, rounds=rounds, tabs=(tab,)),
               lga=["All"] + DATA["lgas"], ftype=["All"] + C.FACILITY_TYPES,
               rounds=W.IntRangeSlider(value=(1, DATA["n_rounds"]), min=1, max=DATA["n_rounds"]),
               tab=["overview", "access", "hrh", "supply", "vaccines"])
except ImportError:
    print("pip install ipywidgets for live filters; call dashboard(...) directly otherwise.")

The full static dashboard, embedded¶

Renders when this notebook is opened in Jupyter / VS Code (it loads the GitHub Pages build); GitHub's static viewer shows only the link.

IFrame("https://chidex-coder.github.io/ls2-facility-survey-analytics/", width="100%", height=900)
con.close()