
How I transformed raw GPS telemetry data into a machine learning-ready training set for a general-purpose elk location prediction model—and how I’ll validate it on my upcoming hunt
The Problem
When I started building PathWild, an AI-powered platform for predicting wildlife locations, I had a clear goal: create a general-purpose model that could predict elk locations across Wyoming based on environmental conditions, terrain, and temporal factors. To validate the model, I plan to use it for my upcoming hunt in Wyoming’s Area 048 during October 2026, but the system itself is designed to work anywhere in the state.
But I faced a classic machine learning problem — I needed training data that represented actual elk behavior, not just theoretical models. The challenge? Elk don’t come with labeled datasets. I needed to find real GPS tracking data, understand its structure, clean it, and transform it into features that my model could learn from. This is the story of how I went from discovering public datasets to creating a production-ready training pipeline.
In my previous post, I documented building the initial heuristics that encode domain knowledge about elk behavior. Those heuristics gave me a working prototype, but to move from heuristics to machine learning, I need real training data from actual elk movements.
Finding the Right Data
Following the approach outlined in Emmanuel Ameisen’s Building Machine Learning Powered Applications, I started by defining what “good” data would look like:
- Geographic relevance: Data from the Bighorn Mountains or similar terrain
- Temporal coverage: October data (hunting season) preferred, but seasonal patterns acceptable
- Sample size: Enough GPS points to learn meaningful patterns
- Data quality: Clean coordinates, timestamps, and metadata
After researching public datasets, I identified three primary sources from the USGS Science Data Catalog:
1. South Bighorn Herd Migration Routes ⭐
- Why it matters: Same geographic region as Area 048
- Coverage: Western foothills to mountainous regions, altitudinal migrations
- Data: Spring/fall migration routes, ~4,000 elk population
- Link: USGS Data Catalog
2. National Elk Refuge GPS Collar Data
- Why it matters: Long time series (2006-2015), well-documented patterns
- Coverage: 17 adult female elk, migration from National Elk Refuge to Yellowstone
- Data: GPS locations with timestamps, seasonal patterns
3. Southern Greater Yellowstone Ecosystem (GYE)
- Why it matters: Large sample size (288 elk), statistical robustness
- Coverage: 22 Wyoming winter supplemental feedgrounds
- Data: GPS locations during brucellosis risk period (February-July)
The Exploration Process
Rather than immediately building a complex data pipeline, I followed Ameisen’s advice: start simple, iterate based on what you learn. I created Jupyter notebooks to explore each dataset individually, understanding their structure before attempting integration.
Step 1: Load and Inspect
For the South Bighorn dataset, I started with a simple shapefile load:
import geopandas as gpd
from pathlib import Path
DATA_DIR = Path("../data/raw")
BIGHORN_FILE = DATA_DIR / "elk_southern_bighorn" / "Elk_WY_Bighorn_South_Routes_Ver1_2020.shp"
gdf = gpd.read_file(BIGHORN_FILE)
print(f"Shape: {gdf.shape}")
print(f"Columns: {list(gdf.columns)}")
print(f"CRS: {gdf.crs}")
What I learned: The data came as LineString geometries (migration routes), not individual GPS points. I’d need to extract points along these routes to create training examples.
Step 2: Extract Training Points
Migration routes are continuous lines, but machine learning models need discrete training points. I created a function to sample points along each route:
def extract_points_from_routes(gdf, points_per_route=100):
"""Extract discrete points from LineString migration routes"""
all_points = []
for idx, row in gdf.iterrows():
geom = row.geometry
# Handle both LineString and MultiLineString
if geom.geom_type == 'MultiLineString':
for line in geom.geoms:
points = sample_points_along_line(line, points_per_route)
all_points.extend(points)
else:
points = sample_points_along_line(geom, points_per_route)
all_points.extend(points)
return gpd.GeoDataFrame(all_points, crs=gdf.crs)
This gave me 4,650 discrete GPS points from the South Bighorn routes—enough to start training, but I’d need more for robust generalization.
Step 3: Calculate Geographic Relevance
While PathWild is a general-purpose model, I wanted to understand geographic patterns in the training data. Since I’ll be validating the model in Area 048, I loaded the official hunt area boundary from Wyoming Game and Fish Department to analyze which migration routes pass through or near this region:
from src.data.hunt_areas import load_area_048_shapefile
area_048_gdf = load_area_048_shapefile()
area_048_polygon = area_048_gdf.geometry.iloc[0]
# Calculate distance from each point to Area 048 boundary
points_gdf['distance_to_area_048_km'] = points_gdf.geometry.apply(
lambda geom: distance_to_polygon_boundary(geom, area_048_polygon)
)
# Flag points inside the hunt area
points_gdf['inside_area_048'] = points_gdf.geometry.apply(
lambda geom: area_048_polygon.contains(geom)
)
Key insight: Only 2,225 points (48%) were within 50km of Area 048. While the model is general-purpose, understanding geographic distribution helps ensure I have representative training data across different terrain types and elevations—important for model generalization.
Step 4: Visualize to Understand
Ameisen emphasizes the importance of visualization for understanding data quality. I created a map showing migration routes, the Area 048 boundary, and proximity zones:

Migration routes (blue lines) overlaid on Area 048 boundary (red polygon). The orange dashed circle shows a 50km radius for reference. Points inside the polygon are within the hunt area.
The visualization revealed several important patterns:
- Migration routes cluster in the western foothills (winter range)
- Several routes pass directly through Area 048
- The 50km radius captures most relevant migration activity
- Routes show clear altitudinal patterns (low elevation in winter, high in summer)
Step 5: Prepare for Integration
Before integrating with PathWild’s feature engineering pipeline, I standardized the data format:
pathwild_data = pd.DataFrame({
'latitude': points_gdf['latitude'],
'longitude': points_gdf['longitude'],
'route_id': points_gdf['route_id'],
'distance_to_area_048_km': points_gdf['distance_to_area_048_km'],
'inside_area_048': points_gdf['inside_area_048'],
'season': points_gdf['season'], # 'sp' (spring) or 'fa' (fall)
'year': points_gdf['year'],
'firstdate': points_gdf['firstdate'],
'lastdate': points_gdf['lastdate']
})
pathwild_data.to_csv('../data/processed/south_bighorn_points.csv', index=False)
This standardized format sets the stage for the next critical step: adding environmental context using PathWild’s DataContextBuilder module.
Step 6: Adding Environmental Context with DataContextBuilder
GPS coordinates and timestamps alone aren’t enough to predict elk behavior. Elk respond to environmental conditions—elevation, weather, snow depth, vegetation quality, water availability, and predation risk. That’s where DataContextBuilder comes in.
DataContextBuilder is PathWild’s feature engineering module that enriches location-time pairs with comprehensive environmental data. It takes a simple location (lat/lon) and date, and returns a rich context dictionary with dozens of features.
Here’s how it works:
from src.data.processors import DataContextBuilder
from pathlib import Path
# Initialize the builder with data directory
data_dir = Path("data")
context_builder = DataContextBuilder(data_dir)
# Build context for a specific location and date
location = {"lat": 43.4105, "lon": -107.5204}
date = "2017-10-15"
context = context_builder.build_context(location, date)
The build_context method returns a dictionary containing:
Static terrain features (sampled from raster data):
elevation– Digital Elevation Model (DEM) valueslope_degrees– Terrain steepnessaspect_degrees– Terrain orientation (north-facing vs south-facing)canopy_cover_percent– Forest canopy densityland_cover_type– NLCD land cover classification
Water and access features (calculated from vector data):
water_distance_miles– Distance to nearest water sourcewater_reliability– Water source permanence scoreroad_distance_miles– Distance to nearest roadtrail_distance_miles– Distance to nearest trail
Security and predation:
security_habitat_percent– Percentage of secure cover in surrounding areawolves_per_1000_elk– Predicted wolf densitybear_activity_distance_miles– Distance to known bear activity
Temporal features (date-specific, fetched from APIs):
snow_depth_inches– SNOTEL station datasnow_water_equiv_inches– Snow water equivalenttemperature_f– Historical or forecasted temperatureprecip_last_7_days_inches– Recent precipitationndvi– Normalized Difference Vegetation Index (vegetation quality)irg– Integrated Resource Gradient (forage quality metric)
The module handles the complexity of:
- Loading static data layers (DEM, land cover, water sources) on initialization
- Sampling raster data at specific coordinates using proper projection handling
- Fetching temporal data from SNOTEL (snow), NOAA (weather), and satellite APIs (vegetation)
- Calculating derived metrics like security habitat percentage and predator densities
- Caching to avoid redundant API calls during training
For each GPS point in my training datasets, I can now call build_context with the point’s coordinates and timestamp to get a complete feature vector. This transforms raw location data into ML-ready features that capture the environmental conditions elk actually respond to.
# Example: Enrich training data with environmental features
for idx, row in pathwild_data.iterrows():
location = {"lat": row['latitude'], "lon": row['longitude']}
date = row['firstdate'].strftime('%Y-%m-%d')
context = context_builder.build_context(location, date)
# Add environmental features to the training row
for key, value in context.items():
pathwild_data.at[idx, key] = value
Lessons Learned
1. Start with Exploration, Not Implementation
Creating separate notebooks for each dataset let me understand their unique characteristics before building a unified pipeline. The National Elk Refuge data came as CSV with different column names. The Southern GYE data used UTM coordinates instead of lat/lon. Each required custom handling.
2. Geographic Context Matters
Simply having GPS points isn’t enough — I needed to understand their relationship to my target area. Calculating distances to the hunt area boundary (not just a center point) gave me a more accurate measure of relevance.
3. Visualization Reveals Patterns
The map visualization showed migration routes I wouldn’t have noticed in tabular data. Seeing that routes cluster in specific areas helped me understand where to focus feature engineering efforts.
4. Iterate on Data Quality
My first extraction used 50 points per route. After visualizing, I increased to 100 points per route for better coverage. This iterative refinement is central to Ameisen’s approach—build, measure, learn, improve.
Next Steps
With three processed datasets (South Bighorn, National Elk Refuge, Southern GYE), I now have:
- 4,650 points from South Bighorn (geographic match)
- Thousands of points from National Elk Refuge (temporal patterns)
- Tens of thousands from Southern GYE (statistical robustness)
The next phase involves:
- Feature engineering: Using
DataContextBuilderto add environmental features to all GPS points - Negative examples: Generating random points not on migration routes for classification training
- Balanced sampling: Ensuring geographic and temporal diversity in the training set
- Model training: Training XGBoost with the combined, feature-rich dataset to create a general-purpose prediction model
- Building a training pipeline: Currently, I’m using Jupyter notebooks for data processing, but I need a more automated pipeline to easily incorporate new training datasets as I iterate on the model. This will be critical as I discover additional data sources or need to retrain with updated data.
- Validation: Testing the model on Area 048 during October 2026 to validate real-world performance
The Takeaway
Building machine learning applications isn’t just about algorithms — it’s about understanding your data deeply before you try to learn from it. By starting with exploration notebooks, visualizing spatial relationships, and iterating on data quality, I transformed raw GPS telemetry into a training set that actually represents the problem I’m trying to solve.
As Ameisen writes: “The best model in the world won’t help if your data doesn’t represent the problem you’re solving.” For PathWild, that means ensuring my training data reflects real elk behavior across diverse geographic and temporal contexts — not just one specific location. By combining multiple datasets from different regions and time periods, I’m building a model that can generalize to new locations, which I’ll validate with real-world testing in Area 048 next October.
Technical Details
All code and notebooks are available in the PathWild repository. The key files:
notebooks/02_explore_south_bighorn.ipynb– South Bighorn dataset explorationnotebooks/03_explore_national_refuge.ipynb– National Elk Refuge explorationnotebooks/04_explore_southern_gye.ipynb– Southern GYE explorationsrc/data/hunt_areas.py– Hunt area boundary loading utilitiessrc/data/processors.py–DataContextBuilderclass and environmental data clientsdata/processed/*.csv– Processed training datasets
The visualization was generated using GeoPandas and Matplotlib, with UTM projection for accurate distance calculations.
Building PathWild has been an exercise in iterative development—starting simple, learning from the data, and refining the approach. This data exploration phase sets the foundation for feature engineering and model training. In future posts, I’ll cover building an automated training pipeline to streamline the process of incorporating new datasets, feature engineering with DataContextBuilder, and training the first XGBoost model.
[…] From GPS Collars to Training Data: Building PathWild’s Elk Location Dataset […]