This project builds an end-to-end urban mobility analytics system using NYC taxi and for-hire vehicle data on Google Cloud. It combines BigQuery for scalable data modeling and Vertex AI Gemini for natural language to SQL generation, deployed through a Streamlit application on Cloud Run.
The system enables users to ask questions in plain English and receive SQL-backed insights from a unified dataset covering NYC Yellow Taxi, Green Taxi, and For-Hire Vehicle (FHV) trips.
NYC taxi and mobility data is fragmented across multiple datasets with inconsistent schemas and varying data quality. This project addresses these challenges by:
- Integrating multiple mobility datasets into a unified analytical model
- Enabling scalable analysis of urban transportation patterns
- Supporting natural language querying through an LLM-powered interface
- Evaluating the reliability of LLM-generated SQL in real-world analytics
All data is stored in Google Cloud Storage:
gs://msca-bdp-data-open/final_project_taxi/
- Yellow Taxi β Monthly Parquet files
- Green Taxi β Monthly Parquet files
- FHV (For-Hire Vehicles) β Monthly Parquet files (sparse schema)
- Taxi Zone Lookup β CSV mapping
LocationID β Borough, Zone
Coverage: Calendar Year 2025
Access Method: BigQuery external tables (no local downloads required)
UrbanMobilityAnalytics/
β
βββ .streamlit/
β βββ config.toml
β
βββ sql/
β βββ 01_create_dataset.sql
β βββ 02_external_tables.sql
β βββ 03_unify_3_tables.sql
β βββ 04_Final_Analytic_Table.sql
β βββ 05_Data_Analytics.sql
β
βββ app.py
βββ Dockerfile
βββ pyproject.toml
βββ poetry.lock
βββ .dockerignore
β
βββ README.md
β
βββ .gitignore
- Yellow Taxi
- Green Taxi
- FHV
- Zone Lookup
- Standardized schema across all trip types
- Added
trip_type - Missing fields mapped to
NULL
Materialized table used by the application with:
- Joined pickup/dropoff zones
- Derived features:
- pickup_date
- pickup_hour
- pickup_month
- pickup_dayofweek
- day_type
- tip_rate
This section documents schema-level cleaning decisions applied before modeling.
| Column | Decision | Reason |
|---|---|---|
| passenger_count | Keep | Missing due to vendor reporting, not invalid |
| RatecodeID | Keep | Missing fare type but still useful metadata |
| store_and_fwd_flag | Optional drop | Not used in analysis |
| congestion_surcharge | Fill NULL β 0 | Not applicable to all rides |
| airport_fee | Fill NULL β 0 | Only applies to airport trips |
- passenger_count β keep
- RatecodeID β keep
- store_and_fwd_flag β optional drop
- congestion_surcharge β fill 0
- airport_fee β fill 0
| Column | Decision | Reason |
|---|---|---|
| ehail_fee | DROP | 100% NULL (unused column) |
| passenger_count | Keep | Valid but incomplete |
| trip_type | Keep | Some missing due to older records |
| cbd_congestion_fee | Fill NULL β 0 | Rarely applicable |
| store_and_fwd_flag | Optional drop | Not used in analysis |
- ehail_fee β drop column
- passenger_count β keep
- trip_type β keep
- cbd_congestion_fee β fill 0
- store_and_fwd_flag β optional drop
This dataset is the most sparse and requires special handling.
| Column | Decision | Reason |
|---|---|---|
| PUlocationID | Keep (filter selectively) | ~82% NULL, but expected in FHV |
| DOlocationID | Keep | Same reasoning as pickup |
| SR_Flag | DROP | 100% NULL (unused column) |
| Affiliated_base_number | Keep | Only 39 nulls |
- High NULL rate in location fields is expected behavior
- Used only for:
- volume analysis
- limited geographic analysis
- Not reliable for full spatial modeling
- Fully NULL columns (e.g.,
ehail_fee,SR_Flag)
- Partially missing but meaningful fields
- Metadata columns useful for grouping
- NULL normalization (e.g., fees β 0)
- Schema alignment across taxi types
- Consistent handling of missing geography
- Trip volume comparison across services
- Temporal demand patterns (hour/day/month)
- Geographic distribution (zones & boroughs)
- Fare, distance, and tipping behavior
- Route-level mobility patterns
- Outlier and anomaly detection
This section describes the design decisions behind the AI-powered analytics application.
The application is designed as a lightweight AI analytics layer on top of BigQuery, not a full backend system.
Key principle:
Keep computation in BigQuery, not in the application layer.
- Model:
gemini-2.5-flash - Purpose: natural language β SQL conversion
- Prompt includes:
- full schema definition
- strict rules (SELECT-only)
- column constraints
The LLM is constrained with explicit schema grounding to reduce hallucinated columns.
The app includes a post-processing step:
- Removes markdown formatting (```sql)
- Extracts valid SQL using regex
- Ensures only
SELECTorWITHqueries are allowed
LLMs frequently return:
- explanations
- markdown fences
- invalid SQL formatting
Before execution, SQL is validated:
SELECT WITH (CTEs)
- INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, MERGE, etc.
`LIMIT 1000` if missing
Prevents:
- accidental data modification
- expensive full-table scans
- unsafe query execution
Special logic is applied when queries involve:
- fare
- tip
- distance
- revenue
FHV data is excluded from geographic queries due to missing fields.
Automatically injects:
WHERE trip_type != 'fhv'- Uses BigQuery Python client
- Applies 10GB query limit safety guard
- Always queries final materialized table
- Avoids runtime joins for performance
- All heavy transformations are precomputed in BigQuery, not in Streamlit.
The app dynamically detects chart types:
Logic:
- If time column β line chart
- If categorical column β bar chart
- If lat/lon β map view
- Otherwise β default bar chart
- Streamlit caching:
- BigQuery client cached (@st.cache_resource)
- Gemini model cached
- SQL generation cached (@st.cache_data)
- Avoid repeated model calls for identical questions
- Lower latency
- Reduced Vertex AI cost
- No API keys in code
- Uses Cloud Run service account authentication
- IAM-based access control:
- BigQuery Data Viewer
- BigQuery Job User
- Vertex AI User
- Natural language β SQL generation
- SQL validation layer
- BigQuery execution
- Automatic chart rendering
- Example-driven sidebar UI
- No raw data locally
- Use BigQuery external tables only
- Final app must query a materialized table, not views
- No hardcoded credentials allowed
- App must be live at submission time
poetry env activate
poetry run streamlit run app.py