Syam Sundar Nallamekala

WorkOhio Department of TransportationMay – Aug 2025

Traffic demand forecasting

Data analyst and AI programmer

8.92% MAPE predicting annual average daily traffic at 919 count stations across four Columbus-area counties, from census-tract demographics alone.

8.92%MAPE across 919 count stations

The problem

Transportation agencies plan decades ahead. Before a lane is widened or an interchange rebuilt, someone has to answer: how much traffic will use this road in 2040?

The standard tool is a four-step travel demand model — trip generation, distribution, mode choice, assignment. It is well understood and slow to run, and its assignment step encodes fixed behavioural assumptions that are difficult to update as a region changes.

ODOT wanted to know whether machine learning could produce station-level traffic forecasts directly from demographic projections, for four counties in the Columbus metropolitan area. The practical question underneath: when a developer puts up an apartment complex, what happens to traffic at each nearby count station?

This was the third assignment I was given at ODOT, after Power BI dashboards for investment and construction data, and a speed estimation system added to their existing vehicle detection pipeline.

The data

Five sources, all agency-provided.

SourceContents
Origin-destination surveyAverage daily O-D traffic between tract pairs
Tract summary, 328 tractsPopulation, income, households, workers — annually, 2013–2019
Station data, 919 stationsAADT by year, joined to census tract GEOID
Variable limitsPer-variable scaling maxima
TAZ projectionsPopulation, income, households, workers — 2020 through 2050

GEOIDs resolve to Ohio census tracts, mostly Franklin County. Three defects in the data constrained everything downstream.

Seven years is seven samples. The features are tract-year aggregates, so one year is one observation. Training on 2013–2018 means a training set of six. This is the binding constraint on the entire project and it is not solvable with a better architecture.

The projections are not observations. Future-year population values arrive as non-integer interpolations — a tract holds 1,953.58 people in 2020. They are a demographer’s model output. Any error in them propagates into mine, and my reported accuracy does not include it.

Two schemas. Historical columns are POPN2013, Income_2013, HH_2013; projections are POP2020, HHINC2020, HH2020. The rename had to be handled at the boundary between validation and forecasting, which is exactly where a silent mismatch does the most damage.

Approach

The key decision was not to treat this as time-series forecasting. With six samples, no temporal model can be fit honestly.

Instead I reframed it as learning a spatial assignment operator: given a demand surface over 328 tracts, produce traffic at 919 stations. Year becomes a way of generating training examples rather than a variable to extrapolate along.

That reframing needed a way to make demand respond to demographics. The step that does it:

# OD flows normalised to per-capita trip rates by origin tract
referenceMatrix = matrix.drop('POPN2018', axis=1)
referenceMatrix = referenceMatrix.div(matrix['POPN2018'], axis=0)

# Re-expanded against any year's population
od_matrix = referenceMatrix.mul(tract_data[f'POPN{year}'], axis=0)

Dividing the O-D matrix by origin population converts absolute flows into trips per person per destination. Multiplying back by a different year’s population regenerates the full demand surface under that year’s demographics.

The assumption is explicit and worth stating plainly: per-capita trip rates and destination preferences are held constant; population and socioeconomics drive all change. That is strong, and it is the same assumption most growth-factor methods make — but here it is a single visible line of code rather than a buried coefficient.

It is also what makes the development scenario work. Add a thousand residents to a tract, and their trips distribute along that tract’s existing destination profile, arriving at whichever stations serve those routes.

Each year’s input is 328 × 332: the 328-destination demand row per tract, plus population, income, households and workers. Output is 919 station AADT values.

Implementation

TensorFlow and Keras. A fully connected network, flattening the demand surface and widening progressively to the station vector:

nn_model = Sequential([
    Flatten(input_shape=(328, 332)),
    Dense(328, activation='relu'),
    Dense(384, activation='relu'),
    Dense(512, activation='relu'),
    Dense(640, activation='relu'),
    Dense(768, activation='relu'),
    Dense(896, activation='relu'),
    Dense(919)
])
nn_model.compile(optimizer=Adam(learning_rate=0.00001),
                 loss='mean_absolute_error')

MAE rather than MSE, because station AADT spans two orders of magnitude and squared error would let a few high-volume freeway stations dominate the gradient.

All variables scaled by per-variable maxima so income (tens of thousands) and household counts (hundreds) enter on comparable footing. Seeds fixed for reproducibility.

The split is temporal and strictly ordered — 2013–2018 train, 2019 test. No shuffling: a random split across years would leak, since tract demographics are highly autocorrelated year to year.

Trained full-batch for 1000 epochs at a deliberately small learning rate.

Results

8.92% MAPE at 919 stations on the held-out 2019 year. Test MAE 0.0088 in scaled units against 0.0033 at convergence on training data.

The actual-versus-predicted scatter tracks the identity line closely across the range, with the tightest agreement at low and high volumes and the widest scatter in the mid range — a handful of stations over-predicted by roughly 1.5× to 2×.

Forecasting 2020–2050 against the TAZ projections gives average station AADT rising from about 10,470 to 13,730, an average annual growth rate of 0.91%.

Deliverables: a per-station AADT projection table for every year 2020–2050, three milestone presentations, and a final technical report.

Limitations and what I'd do differently

The headline number needs its conditions attached, and they are severe.

The model has 38,375,647 parameters and was trained on six examples — roughly 6.4 million parameters per training sample. The architecture cannot be justified by the data; it worked because the mapping it learns is close to linear and the inputs are heavily structured, not because 38M parameters were warranted. A model two orders of magnitude smaller would very likely perform the same or better, and I would size it by validation rather than by intuition.

8.92% has no error bar. It is one number from one held-out year. With seven years available, leave-one-year-out cross-validation gives seven folds and a distribution instead of a point estimate. That is a few hours of work and I should have done it.

There is no baseline. The standard alternative — applying a regional growth factor to each station’s last observed count — was never run. Without it, 8.92% cannot be called good or bad. It is the first thing I would add, because a learned model that fails to beat a growth factor is a negative result worth reporting.

The 2050 projection is smoother than it should be. Average AADT rises almost perfectly linearly at 0.91% per year. That regularity comes from the model being close to linear in population and the population projections being smooth interpolations. The network is doing real work on spatial assignment; the temporal shape is largely inherited from the demographic projections it is fed. The forecast should be read as an assignment of a demographic projection, not as an independent prediction of traffic growth.

It extrapolates outside its training range. Trained on 2013–2019 population levels, it is asked about 2050 levels well beyond them. ReLU networks extrapolate linearly and without warning.

2020 is missing, and 2020 is the interesting year. The training data ends before the pandemic. Testing against 2020–2024 actuals would be the most informative evaluation available — a model that assumes constant per-capita trip rates should fail badly against a year when trip rates collapsed, and quantifying how badly would say more about its operating envelope than another good year ever could.

One silent failure mode in the code: the scaling function returns None when a column matches no known variable, propagating NaN rather than raising. It should assert.

Artifacts

  • Notebook: preprocessing, training, evaluation, and the 2020–2050 projection
  • Actual versus predicted scatter, held-out 2019
  • Average and total AADT projection curves, 2020–2050

The underlying data is the agency’s and is not published here. Figures and tract-level values are proprietary; the method above stands without them.