Nafs Results Analytics — Aseer Region¶
Adel Asiri · CDMP · PMP
Official Nafs results for Aseer schools, 1444–1447 AH.
| Step | Output |
|---|---|
| 1. Load & profile | school-level report 1444–1446 + consolidated 1447 results |
| 2. Data quality (DQ-01…DQ-15) | rule scorecard + reconciliation to official averages |
| 3. Model | conformed rows; 1447 tested counts derived from the proficiency shares |
| 4. Back-test | exact-count recovery on 1445 and 1446 |
| 5. Analysis | trends, gaps, segments, statistics |
| 6. Export | dashboard.json, charts, DQ report |
Privacy: this notebook prints aggregates only. School names and ministerial numbers stay in
data/private/.
import pandas as pd, numpy as np
import nafs_pipeline as P
pd.set_option('display.width', 160)
hist, cur, govs, exports = P.load()
print(f'1444–1446 report : {len(hist):,} rows × {hist.shape[1]} columns')
print(f'1447 results : {len(cur):,} rows (consolidated file); earlier exports: {len(exports):,} rows')
1444–1446 report : 77,076 rows × 21 columns 1447 results : 30,649 rows (consolidated file); earlier exports: 14,671 rows
1 · Profiling¶
Identifier and name columns are profiled but never printed.
SAFE = ['year','grade','domain','gender','authority','enrolled','tested','proficient','prof_rate','score_500','ref_dept','ref_kingdom']
cols = [c for c in SAFE if c in hist.columns]
pd.DataFrame({'dtype': hist[cols].dtypes.astype(str), 'nulls': hist[cols].isna().sum(), 'distinct': hist[cols].nunique()})
| dtype | nulls | distinct | |
|---|---|---|---|
| year | int64 | 0 | 3 |
| grade | int64 | 0 | 3 |
| domain | str | 0 | 3 |
| gender | str | 0 | 2 |
| authority | str | 0 | 3 |
| enrolled | int64 | 0 | 220 |
| tested | int64 | 0 | 204 |
| proficient | int64 | 0 | 149 |
| prof_rate | float64 | 0 | 889 |
| score_500 | float64 | 0 | 70874 |
| ref_dept | float64 | 0 | 140 |
| ref_kingdom | float64 | 0 | 150 |
hist[hist['domain'] == hist['subdomain']].groupby(['year','grade','domain']).agg(rows=('tested','size'), tested=('tested','sum'), proficient=('proficient','sum')).unstack('domain').fillna(0).astype(int)
| rows | tested | proficient | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| domain | الرياضيات | العلوم | القراءة | الرياضيات | العلوم | القراءة | الرياضيات | العلوم | القراءة | |
| year | grade | |||||||||
| 1444 | 3 | 389 | 0 | 389 | 12565 | 0 | 12565 | 3731 | 0 | 4073 |
| 6 | 1137 | 1137 | 1137 | 34186 | 34186 | 34186 | 9536 | 13036 | 11512 | |
| 9 | 751 | 751 | 751 | 33053 | 33053 | 33053 | 5207 | 9638 | 14750 | |
| 1445 | 3 | 319 | 0 | 319 | 12313 | 0 | 12313 | 2945 | 0 | 4172 |
| 6 | 1176 | 1176 | 1176 | 35504 | 35504 | 35504 | 9866 | 12772 | 16408 | |
| 9 | 776 | 776 | 776 | 34077 | 34077 | 34077 | 5830 | 8902 | 18091 | |
| 1446 | 3 | 751 | 0 | 751 | 32218 | 0 | 32218 | 7588 | 0 | 8499 |
| 6 | 1255 | 1255 | 1255 | 39120 | 39120 | 39120 | 14937 | 22428 | 17485 | |
| 9 | 824 | 824 | 824 | 38748 | 38748 | 38748 | 9335 | 16647 | 14883 | |
2 · Data-quality rules¶
Each rule maps to a DAMA dimension and has a status: pass, fixed (remediated in the pipeline) or open (raised with the data owner).
hist, cur, gmap, dq, recon, summary, rec_n = P.data_quality(hist, cur, govs, exports)
print(summary)
dq[['id','dim_en','rule_en','checked','failed','pass_rate','status','action_en']]
{'rows': 107725, 'rows_hist': 77076, 'rows_cur': 30649, 'n_rules': 15, 'passed': 8, 'fixed': 5, 'open': 2, 'recon_checked': 48, 'recon_matched': 48, 'schools': 2308, 'unmapped': 145, 'rec_school_grades': 2962, 'rec_students': 109586, 'rec_bad': 0}
| id | dim_en | rule_en | checked | failed | pass_rate | status | action_en | |
|---|---|---|---|---|---|---|---|---|
| 0 | DQ-01 | Completeness | Ministerial school number present | 107725 | 0 | 100.00 | pass | Quarantine & return to source |
| 1 | DQ-02 | Uniqueness | One row per year/school/grade/sub-domain | 107725 | 0 | 100.00 | pass | Remove duplicates after review |
| 2 | DQ-03 | Validity | Tested ≤ enrolled and proficient ≤ tested | 77076 | 0 | 100.00 | pass | Verify against source file |
| 3 | DQ-04 | Accuracy | Published rate equals proficient ÷ tested | 77076 | 0 | 100.00 | pass | Recompute from counts |
| 4 | DQ-05 | Consistency | Four performance levels sum to 100% | 77076 | 0 | 100.00 | pass | Review level distribution |
| 5 | DQ-06 | Reconciliation | Computed proficiency matches the official depa... | 48 | 0 | 100.00 | pass | Investigate with data owner |
| 6 | DQ-07 | Conformity | Proficiency on a common 0–100 scale | 30649 | 30649 | 0.00 | fixed | Convert 1447 export from 0–1 fraction to % |
| 7 | DQ-08 | Comparability | Average-score scale stable across years | 4 | 1 | 75.00 | fixed | Do not compare scores across years (≈500 in 14... |
| 8 | DQ-09 | Completeness | Education office present on every row | 77076 | 30205 | 60.81 | open | Use governorate reference data; raise with dat... |
| 9 | DQ-10 | Consistency | Office label matches reference list | 46871 | 1328 | 97.17 | fixed | Standardise 7 short labels (e.g. 'Bariq' → 'Ba... |
| 10 | DQ-11 | Master data | One name per ministerial number across years | 2308 | 313 | 86.44 | fixed | Adopt latest-year name as golden record |
| 11 | DQ-12 | Reference data | Governorate known for every school | 2308 | 145 | 93.72 | open | Join on ministerial number; unmatched shown as... |
| 12 | DQ-13 | Completeness | Tested / proficient counts available | 30649 | 30649 | 0.00 | fixed | Tested count rebuilt per school-grade from the... |
| 13 | DQ-14 | Reconciliation | Consolidated 1447 file matches the earlier exp... | 14671 | 0 | 100.00 | pass | Adopt the consolidated file as the primary source |
| 14 | DQ-15 | Consistency | One tested count per school-grade across all s... | 7378 | 0 | 100.00 | pass | Basis for the 1447 count reconstruction |
Reconciliation¶
Proficiency computed from student counts vs the official department average, per year × grade × subject × gender.
r = recon.copy()
print(f"matched {int((r['diff'].abs() <= 0.05).sum())} / {len(r)} · max |diff| = {r['diff'].abs().max():.2f}")
r.pivot_table(index=['year','grade','subject'], columns='gender', values=['calc','official']).round(1)
matched 48 / 48 · max |diff| = 0.05
| calc | official | |||||
|---|---|---|---|---|---|---|
| gender | B | G | B | G | ||
| year | grade | subject | ||||
| 1444 | 3 | MATH | 24.4 | 30.9 | 24.4 | 30.9 |
| READ | 19.6 | 35.4 | 19.5 | 35.4 | ||
| 6 | MATH | 27.1 | 28.7 | 27.1 | 28.7 | |
| READ | 26.3 | 41.1 | 26.3 | 41.1 | ||
| SCI | 35.1 | 41.2 | 35.1 | 41.2 | ||
| 9 | MATH | 16.5 | 15.0 | 16.5 | 15.0 | |
| READ | 36.2 | 53.3 | 36.2 | 53.3 | ||
| SCI | 26.8 | 31.6 | 26.8 | 31.6 | ||
| 1445 | 3 | MATH | 24.5 | 23.7 | 24.5 | 23.7 |
| READ | 26.6 | 36.2 | 26.6 | 36.2 | ||
| 6 | MATH | 28.0 | 27.6 | 28.0 | 27.6 | |
| READ | 39.5 | 53.0 | 39.5 | 53.0 | ||
| SCI | 33.6 | 38.4 | 33.6 | 38.4 | ||
| 9 | MATH | 17.9 | 16.3 | 17.9 | 16.3 | |
| READ | 44.4 | 61.9 | 44.4 | 61.9 | ||
| SCI | 24.2 | 28.1 | 24.2 | 28.1 | ||
| 1446 | 3 | MATH | 50.7 | 23.5 | 50.7 | 23.5 |
| READ | 41.3 | 26.3 | 41.3 | 26.3 | ||
| 6 | MATH | 38.1 | 38.3 | 38.1 | 38.3 | |
| READ | 39.4 | 50.1 | 39.4 | 50.1 | ||
| SCI | 53.2 | 61.6 | 53.2 | 61.6 | ||
| 9 | MATH | 23.7 | 24.5 | 23.7 | 24.5 | |
| READ | 31.7 | 45.5 | 31.7 | 45.5 | ||
| SCI | 39.2 | 47.0 | 39.1 | 47.0 | ||
3 · Deriving the 1447 tested counts¶
Each published proficiency is an exact fraction proficient ÷ tested. In 1444–1446 every result of a school-grade (all subjects and sub-domains) shares one tested count (DQ-15), so the count is the least common multiple of the reduced denominators of its 7–12 shares.
print(rec_n['n'].describe().round(1))
print('school-grades without an informative share:', int((rec_n['informative'] == 0).sum()))
rec_n.groupby(level='grade')['n'].agg(['count', 'sum', 'median'])
count 2962.0 mean 37.0 std 34.2 min 1.0 25% 14.0 50% 26.0 75% 48.0 max 283.0 Name: n, dtype: float64 school-grades without an informative share: 1
| count | sum | median | |
|---|---|---|---|
| grade | |||
| 3 | 979 | 39733 | 30.0 |
| 6 | 1168 | 32025 | 19.0 |
| 9 | 815 | 37828 | 33.0 |
main, subs, UNM = P.build_model(hist, cur, gmap, govs, rec_n)
main.groupby('year').agg(rows=('tested','size'), results=('tested','sum'))
| rows | results | |
|---|---|---|
| year | ||
| 1444 | 6442 | 226847 |
| 1445 | 6494 | 233369 |
| 1446 | 7739 | 298040 |
| 1447 | 7907 | 289025 |
4 · Back-test of the reconstruction¶
Apply the same method to 1445 and 1446, where the true counts are known.
back = P.backtest(hist)
back
{'exact_1445': 99.91,
'exact_1446': 99.79,
'exact': 99.8,
'max_err': 0.012,
'region_err': 0.006,
'students_1446': [110086, 110042]}
5 · Analysis¶
res = P.analyse(main, subs, govs, UNM)
pd.DataFrame(res['trend'])
| year | region | national | participation | estimated | derived | schools | students | results | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 1444 | 31.51 | 34.59 | 88.55 | False | False | 1989 | 79804 | 226847 |
| 1 | 1445 | 33.85 | 37.51 | 91.07 | False | False | 2012 | 81894 | 233369 |
| 2 | 1446 | 37.51 | 40.57 | 94.07 | False | False | 2208 | 110086 | 298040 |
| 3 | 1447 | 32.54 | NaN | NaN | False | True | 2163 | 109586 | 289025 |
res['grid']
| subject | MATH | READ | SCI | |
|---|---|---|---|---|
| year | grade | |||
| 1444 | 3 | 29.69 | 32.42 | NaN |
| 6 | 27.89 | 33.67 | 38.13 | |
| 9 | 15.75 | 44.63 | 29.16 | |
| 1445 | 3 | 23.92 | 33.88 | NaN |
| 6 | 27.79 | 46.21 | 35.97 | |
| 9 | 17.11 | 53.09 | 26.12 | |
| 1446 | 3 | 23.55 | 26.38 | NaN |
| 6 | 38.18 | 44.70 | 57.33 | |
| 9 | 24.09 | 38.41 | 42.96 | |
| 1447 | 3 | 25.35 | 20.87 | NaN |
| 6 | 46.43 | 36.04 | 56.10 | |
| 9 | 17.52 | 23.56 | 41.67 |
for i in res['insights']:
print('•', i['en'])
• Aseer proficiency rose from 31.5% (1444 AH) to 37.5% (1446 AH), +6.0 pts — reconciled to the official department averages. • 1447 AH proficiency is 32.54% (-4.97 pts vs 1446). The largest drop is Grade 9 Reading (-14.9 pts) and the largest gain Grade 6 Mathematics (+8.2). • Of the -4.97 pt change, -3.57 is real performance change within grade and subject and -1.40 is the shift in who was tested (grade 3 testers -3.8%, grade 6 -16.6% in the same schools). At the 1446 mix, 1447 would be 33.94%. • The gap to the national average is -3.1 pts in 1446 AH vs -3.1 in 1444 AH: the region improved at the national pace without closing the gap. • In 1447 AH the highest proficiency is Grade 6 Science (56.1%) and the lowest Grade 9 Mathematics (17.5%); the weakest sub-domain is Reading comprehension in Grade 3 Reading (21.0%). • In 1447 AH the top governorate is Al-Namas (43.6%) and the lowest Al-Birk (22.2%); most improved since 1446 is Tarib (+5.5). • Boys and girls converge at student level in 1447 AH (32.0% vs 32.9%) after girls led in 1444; at school level the difference is +0.3 pts (not statistically significant). • 224 schools flagged for priority intervention: ≥5 pts below the regional average and declining ≥5 pts more than the region (of 1,831 classified).
pd.DataFrame({k: v for k, v in res['statistics'].items() if isinstance(v, dict) and k not in ('gender_students', 'decomp')}).T
| girls | boys | diff | t | p | n_girls | n_boys | private | public | n_private | n_public | rho | from_year | to_year | value | base | since_first | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| gender | 33.69 | 33.4 | 0.29 | 0.38 | 0.704674 | 1044.0 | 787.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| authority | NaN | NaN | 4.11 | 2.57 | 0.011569 | NaN | NaN | 37.48 | 33.36 | 88.0 | 1743.0 | NaN | NaN | NaN | NaN | NaN | NaN |
| size_corr | NaN | NaN | NaN | NaN | 0.070564 | NaN | NaN | NaN | NaN | NaN | NaN | -0.042 | NaN | NaN | NaN | NaN | NaN |
| change | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 1446.0 | 1447.0 | -4.97 | 37.51 | 6.0 |
Decomposing the 1446 → 1447 change¶
Rate effect = change within grade × subject at the 1446 mix; mix effect = shift in the share of results by grade × subject.
res['statistics']['decomp']
{'rate_effect': -3.57,
'mix_effect': -1.4,
'standardised': 33.94,
'cohort_change': {3: -3.8, 6: -16.6, 9: -2.4}}
Priority schools (masked)¶
The public list uses P-codes; the named list is written to data/private/ only.
pd.DataFrame(res['priority'])[['id','gov_en','stage','gender','prev','rate','change','rel','tested']]
| id | gov_en | stage | gender | prev | rate | change | rel | tested | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | P-01 | Rijal Alma | ابتدائية | بنات | 88.7 | 15.0 | -73.7 | -68.7 | 27 |
| 1 | P-02 | Bisha | ابتدائية | بنين | 84.4 | 23.9 | -60.5 | -55.6 | 30 |
| 2 | P-03 | Muhayil | ابتدائية | بنات | 45.7 | 6.8 | -38.8 | -33.8 | 42 |
| 3 | P-04 | Ahad Rufaidah | ابتدائية | بنات | 65.0 | 17.5 | -47.5 | -42.5 | 42 |
| 4 | P-05 | Rijal Alma | ابتدائية | بنات | 65.0 | 19.8 | -45.2 | -40.3 | 45 |
| 5 | P-06 | Rijal Alma | ابتدائية | بنات | 27.7 | 1.5 | -26.2 | -21.2 | 56 |
| 6 | P-07 | Rijal Alma | ابتدائية | بنات | 36.3 | 6.7 | -29.6 | -24.6 | 43 |
| 7 | P-08 | Tathlith | متوسطة | بنات | 52.0 | 14.9 | -37.1 | -32.1 | 25 |
| 8 | P-09 | Rijal Alma | ابتدائية | بنات | 62.5 | 20.6 | -41.9 | -36.9 | 31 |
| 9 | P-10 | Abha | ابتدائية | بنات | 47.1 | 13.5 | -33.5 | -28.6 | 42 |
| 10 | P-11 | Abha | ابتدائية | بنات | 44.7 | 12.5 | -32.2 | -27.3 | 35 |
| 11 | P-12 | Bisha | ابتدائية | بنات | 73.3 | 26.9 | -46.4 | -41.4 | 25 |
6 · Charts & export¶
P.charts(res, dq, govs)
feed = P.export(res, dq, recon, summary, govs, back)
from IPython.display import Image, display
for f in sorted(P.CHARTS.glob('*.png')):
display(Image(filename=str(f), width=680))