PRJ-03Data Analytics

DepEd Enrollment Analytics Dashboard

DepEd Enrollment Analytics Dashboard

Role

Technical Lead & Data Engineer

Team

8 members

Period

March 2025 – May 2025

Status

Shipped

Tech Stack

Python, Dash, Flask, Plotly, pandas, SQLAlchemy, SQLite, SCSS

Tools

openpyxl, Flask-Caching, GeoJSON, Conda, Git

“A dashboard that turns DepEd's 60,000-school enrollment spreadsheets into a queryable warehouse, built around a normalization pipeline and an exploratory data analysis.”

Overview

Making Public Education Data Actually Public

PolyCast overview

The Philippine Department of Education publishes annual school-level enrollment data as large Excel workbooks with more than 60,000 rows and around 80 columns of student counts. Although the data is public and detailed, answering basic questions often requires manually building complex pivot tables and understanding how the columns map to grade, gender, strand, and location.

This project transforms those workbooks into a relational data warehouse with an interactive dashboard. Users can explore enrollment by location down to the barangay level, senior-high track and strand, school classification, and program offering.

Built as a six-week Big Data Analytics course project by a team of eight, the system was designed to make public education data easier to explore for researchers, planners, and students.

The Problem

A Simple Question, Buried in 80 Columns

DepEd’s workbook is a wide table designed to show each school’s data in one row. That works for browsing, but makes exploratory analysis difficult.

A question like how many girls are enrolled in STEM strands in Region VII? requires working across 60,000+ rows, building a pivot table, and knowing which of the 80 columns correspond to grade, gender, and strand. Ask a new question, and most of that work starts over.

The real problem sits deeper than the pivot table. Grade level, gender, and senior-high strand aren’t values in the data at all. They’re encoded directly into the column names:

K Male │ K Female │ G1 Male │ … │ G11 ACAD STEM Female │ … │ G12 ARTS Male

A spreadsheet can filter and group by a value. It cannot filter by half of a column heading. So every question that treats grade or gender as a category, which is nearly every interesting question, has to be answered by hand, from scratch, every time.

  • Totaling one region means summing roughly 80 separate columns.
  • Comparing boys against girls means knowing which of those 80 columns belong to which gender.
  • Each new question rebuilds the pivot from zero. None of the work carries over.

The fix is straightforward: unpivot the wide table so grade, gender, track, and strand become queryable values instead of fragments of column names.

The actual work was making that hold up against a real government spreadsheet, where the header row sits at a different depth from year to year, and a purely elementary school still carries a full set of empty senior-high columns that a naive parser would assume were meant to hold data.

My Role

Technical Lead & Data Engineer

I was the technical lead and the data engineer on a eight-person team, which split into a project manager, data engineering, data science, and data analysis. My main responsibility was extracting data from Excel, transforming it into a structured schema, and serving it efficiently enough for the dashboard to query.

My contributions:

  • Built the ETL pipeline end to end, from workbook extraction through the seven wide-to-long transforms to the SQLite load.

  • Designed a seven-table schema built around a central enrollment fact table, with grade level and school details resolved through linked lookup tables. Kept it at this level rather than normalizing further, since a simpler backend was easier for the rest of the team to build against.

  • Wrote smart_filter(), the single query function every chart on the dashboard reads through, and made it memoized so one filter change costs one database read rather than one per chart.

  • Handled workbook inconsistencies by detecting header rows, resolving school years, normalizing location, masking counts outside each school’s declared offerings, and other considerations.

  • Created the frontend boilerplate and chart slots for the data analyst team, while also contributing to some frontend design to help complete the project.

I also coordinated the team’s branches and integration accross the team. With eight people working in parallel on the course project, keeping changes aligned was part of the job.

System Architecture

Three stages: ingest, query, present

Ingestion (runs on upload)

Extractopenpyxl reads the workbook; the header row is detected by scoring rows against known column names, and the school year is resolved from the file
Raw wide DataFrame
CleanStandardizes column names and blanks out counts outside a school's Modified COC — a purely-elementary school has no G11 columns
Cleaned wide DataFrame
Wide to long transformsSeven transforms un-pivot grade, gender, track and strand out of column names and into rows
Enrollment spineOne row per (school, gender); its AUTOINCREMENT key is what the grade tables reference
Long rows
LoadAppends only rows not already present, table by table, into SQLite: 7 tables, ~1.58M rows

Query (runs on interaction)

smart_filter()Composes SQL in three layers: school attributes, then location join, then a grade-level UNION ALL
One SQL read
Memoized cacheFlask-Caching, 1-hour TTL by default; identical filters never hit the database twice
DataFrameResult cast to pandas categoricals and shared by every chart on the page
Shared DataFrame

Presentation

Chart triggersFilter and tab interactions update Dash store state
State change
Plotly callbacksCallbacks across six report modules redraw the affected charts
ChoroplethPer-region province GeoJSON combined and rendered as an enrollment density map

Why every chart shares one function. The dashboard has dozens of charts. Without this, each chart would fire its own database query. Changing one filter could then trigger dozens of near-identical queries at once, all asking for basically the same data. Instead, every chart calls the same function, smart_filter(). The first chart to run it does the actual database work. Every other chart just reuses that result instead of asking again.

Why the query is composed in three layers. smart_filter() builds its SQL in stages, one filter at a time. First it filters by school details. Then it joins in location data. Then it combines elementary, junior high, and senior high records into one result. Each stage only touches its own filters, so changing one doesn’t disturb the rest. User input is never pasted into the SQL. It’s always passed in as a separate parameter, which prevents SQL injection.

Why SQLite. I chose SQLite for its simplicity, the warehouse is a single file, no server to install or configure, which made it easy for an eight-person team to run locally within six weeks. It was also our first real database project as a team, and SQLite still handled the warehouse’s 1.58 million rows comfortably.

Key Technical Decision

One Builder, Three Grade Levels

Elementary, junior high, and senior high each needed their wide columns turned into long rows. The first version was grouped into three near-identical format, which is inconvenient to maintain.

They now share one builder, _melt_grade_counts(). The steps are the same for all three: select columns, melt, tag gender, parse grade, combine male and female counts, merge in the enroll_id, cast types, stamp the school year.

  • Elementary and junior high differ only by their column mask (technically same).
  • Senior high additionally parses track and strand off the full column name, before it can follow the same steps as the other two.

Two behaviors were deliberately preserved. Elementary drops empty counts before the merge while the other two drop them afterward, which yields the same rows either way. Strandless senior-high tracks such as TVL, SPORTS, and ARTS use an explicit __NaN__ sentinel to represent a missing strand.

One tradeoff worth naming: three transforms write to the database mid-transform, which is normally a mistake. It’s necessary here, though, since SQLite only assigns each row its ID once it’s saved, so rows must be written and read back before anything else can reference them.

System Features

What the dashboard does

Dashboard views
  • Explores enrollment across four areas: location, senior-high track and strand, subclassification, and program offering. Each has its own page and filter set.
  • Drills from region down to barangay. The location hierarchy runs region, province, division, district, municipality, barangay.
  • Maps enrollment density across provinces, using per-region GeoJSON combined at render time.
  • Ingests new school-year workbooks from inside the app. The App Settings page accepts an upload and streams the pipeline log live while it runs, so a five-stage ETL is not a silent wait.
  • Gates access by role. Guests see the public overview; members reach the full analytics set. Restricted routes are enforced server-side in the callback, not only hidden in the navigation.
  • Rebuilds from source in one command. Migrations are tracked in a schema_migrations ledger, so running them twice is a no-op, and seeding is scoped to a named school year for reproducibility.

Tech Stack

What each layer runs on

Layer Choice
Ingestion Python, pandas, openpyxl (extract, clean, seven wide-to-long transforms)
Warehouse SQLite via SQLAlchemy; schema defined as models, applied through tracked migrations
Query smart_filter() — parameterized three-layer SQL, memoized with Flask-Caching
Application Dash on Flask, server-side sessions, role-gated routing
Visualization Plotly Express and Graph Objects; GeoJSON choropleth
Styling Hand-written SCSS compiled to CSS, Inter
Environment Conda or venv, .env configuration read through one settings module

There is no JavaScript build step. The interface is declared in Python and served as React by Dash, which let a team of mostly non-frontend students build a fairly dense dashboard without also learning a frontend toolchain in six weeks.

Results & Learnings

What shipped, and what I would rebuild

The warehouse holds 1.36 million enrollment rows across 60,167 schools in 18 regions for S.Y. 2023-2024, or about 1.58 million rows across all seven tables. A full rebuild from the raw workbook takes roughly 50 seconds and is reproducible from a clone with python setup.py.

The result is the thing the project set out to make true: a question about enrollment by gender, grade, strand, or province is now a filter selection instead of an afternoon of spreadsheet work.

Where it falls short, honestly:

  • There is no test suite. The test/ directory is empty. The pipeline is verified by rebuilding from source and comparing row counts against a known baseline, which catches regressions but is not the same as testing the transforms.
  • Passwords are hashed with unsalted SHA-256. Adequate for seeded demo accounts on a coursework dashboard, not acceptable anywhere near real users.
  • load() deduplicates in memory. It reads the whole target table into a DataFrame to filter out rows that already exist. Correct, but it scales with table size rather than batch size. The project was meant to practice pandas, which is why it’s built this way. Suggested to use INSERT OR IGNORE would let the database handle it instead.
  • The pipeline talks to the UI through a global. With the deadline closing in, transforms were made to write progress straight into a shared log_messages list, which the Settings page reads to stream live updates. It shipped on time, but it ties the pipeline directly to the UI. A cleaner fix would pass in a progress callback instead.
  • The School Profile page was left unfinished when the course ended, and the schema was not normalized further than the team needed.

The lesson I took from it is about where the leverage sits. I expected the interesting work to be in the charts, and it was not. It was in the shape of the data underneath them. Once grade and gender were values instead of column headings, questions that had been afternoon-long spreadsheet exercises became one-line filters, and the analysts on the team stopped asking me to pull numbers for them. The single memoized query function did more for the dashboard’s responsiveness than any chart-level optimization would have.

Resources

Next case study

PolyCast: Assistive Marker for Classroom Capture