πΏ DeepMaxent Tutorial: Species Distribution Modeling
Introduction
Section titled βIntroductionβWelcome to this tutorial on DeepMaxent, a deep learning approach for Species Distribution Modeling (SDM). In this notebook, we will:
- Load and explore biodiversity occurrence data from GBIF
- Visualize species occurrences on interactive maps
- Process environmental rasters (WorldClim bioclimatic variables)
- Prepare training data by aggregating occurrences at raster resolution
- Build input tensors ready for the DeepMaxent model
This tutorial focuses on plant species observations from Costa Rica π¨π·.
1. π¦ Import Libraries
Section titled β1. π¦ Import LibrariesβWe need several libraries for this tutorial: - pandas/numpy: Data manipulation - rasterio: Reading and processing raster files - matplotlib/cartopy: Creating beautiful maps - torch: Building tensors for deep learning - sklearn: Data preprocessing
# Suppress warningsimport warningswarnings.filterwarnings('ignore')
# Core librariesimport numpy as npimport pandas as pdimport osfrom tqdm import tqdm
# Geospatial librariesimport rasteriofrom rasterio.windows import from_boundsfrom rasterio.plot import show
# Machine Learningimport torchfrom sklearn.preprocessing import StandardScaler
# Visualizationimport matplotlib.pyplot as pltimport matplotlib.colors as mcolorsfrom matplotlib.colors import LinearSegmentedColormapimport cartopy.crs as ccrsimport cartopy.feature as cfeature
# DeepMaxent librariesfrom librairies.model import deepmaxent_modelfrom librairies.losses import deepmaxent_lossfrom librairies.utils import set_seed
# Set random seed for reproducibilityset_seed(42)
# Plotting styleplt.style.use('seaborn-v0_8-whitegrid')plt.rcParams['figure.figsize'] = (12, 8)plt.rcParams['font.size'] = 11plt.rcParams['axes.titlesize'] = 14plt.rcParams['axes.labelsize'] = 12
print("β
All libraries loaded successfully!")β
All libraries loaded successfully!2. π¦ Load Biodiversity Data
Section titled β2. π¦ Load Biodiversity DataβWeβll load occurrence data from the Global Biodiversity Information Facility (GBIF). This dataset contains species observations with their geographic coordinates, taxonomy, and observation metadata.
# Load the biodiversity datasetdf = pd.read_csv('data/tutorial/biodiversity_data.csv', sep='\t', low_memory=False)
print(f"π Dataset shape: {df.shape[0]:,} observations Γ {df.shape[1]} columns")print(f"\nπ€ Available columns:\n{df.columns.tolist()[:15]}...")
# Display first few rowsdf.head(3)π Dataset shape: 26,670 observations Γ 50 columns
π€ Available columns:['gbifID', 'datasetKey', 'occurrenceID', 'kingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'infraspecificEpithet', 'taxonRank', 'scientificName', 'verbatimScientificName', 'verbatimScientificNameAuthorship']...| gbifID | datasetKey | occurrenceID | kingdom | phylum | class | order | family | genus | species | β¦ | identifiedBy | dateIdentified | license | rightsHolder | recordedBy | typeStatus | establishmentMeans | lastInterpreted | mediaType | issue | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 991957019 | 9661bc40-f762-11e1-a439-00145eb45e9a | NaN | Animalia | Chordata | Squamata | NaN | Colubridae | Trimetopon | Trimetopon simile | β¦ | NaN | NaN | CC_BY_4_0 | NaN | NaN | NaN | NaN | 2025-08-13T06:26:51.213Z | NaN | COORDINATE_ROUNDED;GEODETIC_DATUM_ASSUMED_WGS8β¦ |
| 1 | 991940453 | 9661bc40-f762-11e1-a439-00145eb45e9a | NaN | Animalia | Chordata | Amphibia | Anura | Eleutherodactylidae | Eleutherodactylus | Eleutherodactylus rhodesi | β¦ | NaN | NaN | CC_BY_4_0 | NaN | NaN | NaN | NaN | 2025-08-13T06:26:57.208Z | NaN | COORDINATE_ROUNDED;GEODETIC_DATUM_ASSUMED_WGS8β¦ |
| 2 | 991853602 | 9661bc40-f762-11e1-a439-00145eb45e9a | NaN | Animalia | Chordata | Amphibia | Anura | Phyllomedusidae | Agalychnis | Agalychnis lemur | β¦ | NaN | NaN | CC_BY_4_0 | NaN | NaN | NaN | NaN | 2025-08-13T06:26:38.847Z | NaN | COORDINATE_ROUNDED;GEODETIC_DATUM_ASSUMED_WGS8β¦ |
3 rows Γ 50 columns
3. π Data Exploration & Visualization
Section titled β3. π Data Exploration & VisualizationβLetβs explore the dataset to understand what kind of data we have:
# Clean coordinates and filter valid datadf['decimalLatitude'] = pd.to_numeric(df['decimalLatitude'], errors='coerce')df['decimalLongitude'] = pd.to_numeric(df['decimalLongitude'], errors='coerce')
# Remove rows with missing coordinates or speciesdf_clean = df.dropna(subset=['decimalLatitude', 'decimalLongitude', 'species'])
print(f"π Valid observations with coordinates: {len(df_clean):,}")print(f"\nπ Distribution by Kingdom:")print(df_clean['kingdom'].value_counts())
print(f"\nπ Observation types:")print(df_clean['basisOfRecord'].value_counts())π Valid observations with coordinates: 26,670
π Distribution by Kingdom:kingdomAnimalia 15351Plantae 11313Fungi 6Name: count, dtype: int64
π Observation types:basisOfRecordHUMAN_OBSERVATION 13645PRESERVED_SPECIMEN 12712MATERIAL_CITATION 224MACHINE_OBSERVATION 50OCCURRENCE 35LIVING_SPECIMEN 2MATERIAL_SAMPLE 2Name: count, dtype: int64# Filter for plant observations only (human observations are most reliable)df_filtered = df_clean[ (df_clean['kingdom'] == 'Plantae') & (df_clean['basisOfRecord'] == 'HUMAN_OBSERVATION')].copy()
print(f"πΏ Plant observations (human observations): {len(df_filtered):,}")print(f"π± Number of unique species: {df_filtered['species'].nunique():,}")πΏ Plant observations (human observations): 1,145π± Number of unique species: 102# Summary statistics of filtered dataprint("π Dataset Summary")print("=" * 50)print(f"Total observations: {len(df_filtered):,}")print(f"Unique species: {df_filtered['species'].nunique():,}")print(f"Unique locations: {df_filtered.groupby(['decimalLongitude', 'decimalLatitude']).ngroups:,}")print(f"\nπ Geographic extent:")print(f" Longitude: [{df_filtered['decimalLongitude'].min():.3f}, {df_filtered['decimalLongitude'].max():.3f}]")print(f" Latitude: [{df_filtered['decimalLatitude'].min():.3f}, {df_filtered['decimalLatitude'].max():.3f}]")print(f"\nπ
Year range: {int(df_filtered['year'].min())} - {int(df_filtered['year'].max())}")π Dataset Summary==================================================Total observations: 1,145Unique species: 102Unique locations: 1,040
π Geographic extent: Longitude: [-85.917, -82.630] Latitude: [8.234, 11.189]
π
Year range: 1989 - 20253.1 πΊοΈ Map of Species Occurrences
Section titled β3.1 πΊοΈ Map of Species OccurrencesβLetβs visualize where the species observations are located. This is crucial to understand the spatial distribution of our data and identify potential sampling biases.
# Create a beautiful map showing all occurrencesfig = plt.figure(figsize=(8, 6))ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
# Define map extent with paddingpadding = 0.5extent = [ df_filtered['decimalLongitude'].min() - padding, df_filtered['decimalLongitude'].max() + padding, df_filtered['decimalLatitude'].min() - padding, df_filtered['decimalLatitude'].max() + padding]ax.set_extent(extent, crs=ccrs.PlateCarree())
# Add map featuresax.add_feature(cfeature.LAND, facecolor='#f0f0f0', edgecolor='none')ax.add_feature(cfeature.OCEAN, facecolor='#cce5ff')ax.add_feature(cfeature.COASTLINE, linewidth=0.8, edgecolor='#555555')ax.add_feature(cfeature.BORDERS, linestyle='--', linewidth=0.5, edgecolor='#888888')ax.add_feature(cfeature.RIVERS, linewidth=0.5, edgecolor='#99ccff')
# Plot occurrence pointsscatter = ax.scatter( df_filtered['decimalLongitude'], df_filtered['decimalLatitude'], c='#2ecc71', s=15, alpha=0.6, edgecolor='#27ae60', linewidth=0.3, transform=ccrs.PlateCarree(), label='Plant occurrences')
# Add gridlinesgl = ax.gridlines(draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--')gl.top_labels = Falsegl.right_labels = False
# Title and legendax.set_title('Plant Species Occurrences in Costa Rica\n(GBIF Data - Human Observations)', fontsize=16, fontweight='bold', pad=20)ax.legend(loc='lower left', fontsize=11)
# Add observation count annotationax.text(0.02, 0.1, f'n = {len(df_filtered):,} observations', transform=ax.transAxes, fontsize=11, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
plt.tight_layout()plt.show()
3.2 π Top Species Distribution
Section titled β3.2 π Top Species DistributionβLetβs look at the most frequently observed species in our dataset:
# Top 15 most observed speciestop_species = df_filtered['species'].value_counts().head(15)
fig, ax = plt.subplots(figsize=(6, 5))
# Create horizontal bar chartcolors_gradient = plt.cm.Greens(np.linspace(0.4, 0.9, len(top_species)))[::-1]bars = ax.barh(range(len(top_species)), top_species.values, color=colors_gradient)
# Customizeax.set_yticks(range(len(top_species)))ax.set_yticklabels([f'$\it{{{s}}}$' for s in top_species.index], fontsize=10) # Italic for species namesax.invert_yaxis()ax.set_xlabel('Number of Observations', fontsize=12)ax.set_title('Top 15 Most Observed Plant Species', fontsize=14, fontweight='bold')
# Add value labelsfor i, (bar, val) in enumerate(zip(bars, top_species.values)): ax.text(val + 5, i, f'{val:,}', va='center', fontsize=9)
ax.spines['top'].set_visible(False)ax.spines['right'].set_visible(False)
plt.tight_layout()plt.show()
print(f" Distribution summary:")print(f" Most observed species: {top_species.index[0]} ({top_species.values[0]:,} obs)")print(f" Median obs per species: {df_filtered['species'].value_counts().median():.0f}")
Distribution summary: Most observed species: Coffea arabica (344 obs) Median obs per species: 23.3 π Temporal Distribution of Observations
Section titled β3.3 π Temporal Distribution of ObservationsβUnderstanding when observations were made helps identify temporal sampling bias:
# Temporal distributionyear_counts = df_filtered['year'].value_counts().sort_index()
fig, ax = plt.subplots(figsize=(6, 3))
ax.fill_between(year_counts.index, year_counts.values, alpha=0.4, color='#3498db')ax.plot(year_counts.index, year_counts.values, color='#2980b9', linewidth=2, marker='o', markersize=4)
ax.set_xlabel('Year', fontsize=12)ax.set_ylabel('Number of Observations', fontsize=12)ax.set_title('Temporal Distribution of Plant Observations', fontsize=14, fontweight='bold')
ax.spines['top'].set_visible(False)ax.spines['right'].set_visible(False)
# Highlight peak yearpeak_year = year_counts.idxmax()peak_count = year_counts.max()ax.annotate(f'Peak: {peak_year}\n({peak_count:,} obs)', xy=(peak_year, peak_count), xytext=(peak_year-3, peak_count*0.85), fontsize=10, arrowprops=dict(arrowstyle='->', color='#e74c3c'), bbox=dict(boxstyle='round', facecolor='white', edgecolor='#e74c3c'))
plt.tight_layout()plt.show()
3.4 π₯ Observation Density Heatmap
Section titled β3.4 π₯ Observation Density HeatmapβA density map helps identify areas with high sampling effort:
# Create density heatmapfig = plt.figure(figsize=(8, 6))ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
# Define map extentpadding = 0.5extent = [ df_filtered['decimalLongitude'].min() - padding, df_filtered['decimalLongitude'].max() + padding, df_filtered['decimalLatitude'].min() - padding, df_filtered['decimalLatitude'].max() + padding]ax.set_extent(extent, crs=ccrs.PlateCarree())
# Add map featuresax.add_feature(cfeature.LAND, facecolor='#f5f5f5', edgecolor='none')ax.add_feature(cfeature.OCEAN, facecolor='#e6f3ff')ax.add_feature(cfeature.COASTLINE, linewidth=0.8)ax.add_feature(cfeature.BORDERS, linestyle='--', linewidth=0.5)
# Create hexbin density plothb = ax.hexbin( df_filtered['decimalLongitude'], df_filtered['decimalLatitude'], gridsize=30, cmap='YlOrRd', mincnt=1, transform=ccrs.PlateCarree(), alpha=0.8)
# Add colorbarcbar = plt.colorbar(hb, ax=ax, shrink=0.7, pad=0.02)cbar.set_label('Number of Observations', fontsize=11)
# Gridlinesgl = ax.gridlines(draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--')gl.top_labels = Falsegl.right_labels = False
ax.set_title('Observation Density Map\n(Hexbin aggregation)', fontsize=16, fontweight='bold', pad=20)
plt.tight_layout()plt.show()
4. πΊοΈ Crop Environmental Rasters
Section titled β4. πΊοΈ Crop Environmental RastersβNow we need to prepare our environmental data. Weβre using WorldClim bioclimatic variables at 10 arc-minutes resolution (~18.5 km at the equator).
These 19 bioclimatic variables are derived from temperature and precipitation data:
| Variable | Description |
|---|---|
| BIO1 | Annual Mean Temperature |
| BIO2 | Mean Diurnal Range |
| BIO3 | Isothermality |
| BIO4 | Temperature Seasonality |
| BIO5-6 | Max/Min Temperature |
| BIO7 | Temperature Annual Range |
| BIO8-11 | Temperature of Wettest/Driest/Warmest/Coldest Quarter |
| BIO12 | Annual Precipitation |
| BIO13-14 | Precipitation of Wettest/Driest Month |
| BIO15 | Precipitation Seasonality |
| BIO16-19 | Precipitation of Wettest/Driest/Warmest/Coldest Quarter |
Weβll crop these global rasters to our study area extent.
# Define study area extent based on occurrence databuffer = 0.1 # Small buffer around occurrences
min_longitude = df_filtered['decimalLongitude'].min() - buffermax_longitude = df_filtered['decimalLongitude'].max() + buffermin_latitude = df_filtered['decimalLatitude'].min() - buffermax_latitude = df_filtered['decimalLatitude'].max() + buffer
print("π Study Area Extent:")print(f" Longitude: [{min_longitude:.4f}, {max_longitude:.4f}]")print(f" Latitude: [{min_latitude:.4f}, {max_latitude:.4f}]")print(f" Area span: {max_longitude - min_longitude:.2f}Β° Γ {max_latitude - min_latitude:.2f}Β°")
# Setup foldersinput_folder = 'data/tutorial/rasters'# input_folder = 'E:/chelsa_data/historical_1981-2010'output_folder = 'data/tutorial/cropped_rasters'os.makedirs(output_folder, exist_ok=True)
# List available rastersraster_files = sorted([f for f in os.listdir(input_folder) if f.endswith('.tif')])print(f"\nπ Found {len(raster_files)} raster files to crop")π Study Area Extent: Longitude: [-86.0172, -82.5302] Latitude: [8.1340, 11.2894] Area span: 3.49Β° Γ 3.16Β°
π Found 19 raster files to crop# Crop all rasters to the study extentprint("βοΈ Cropping rasters to study area...")
for filename in tqdm(raster_files, desc="Processing"): input_path = os.path.join(input_folder, filename) output_path = os.path.join(output_folder, filename)
with rasterio.open(input_path) as src: # Get the window for our extent window = from_bounds( min_longitude, min_latitude, max_longitude, max_latitude, transform=src.transform )
# Read cropped data data = src.read(window=window)
# Update metadata meta = src.meta.copy() meta.update({ 'height': int(window.height), 'width': int(window.width), 'transform': rasterio.windows.transform(window, src.transform) })
# Write cropped raster if window.height > 0 and window.width > 0: with rasterio.open(output_path, 'w', **meta) as dst: dst.write(data)
print(f"\nβ
Successfully cropped {len(raster_files)} rasters!")
# Check the cropped raster dimensionswith rasterio.open(os.path.join(output_folder, raster_files[0])) as src: print(f"\nπ Cropped raster dimensions: {src.width} Γ {src.height} pixels") print(f" Resolution: {abs(src.transform[0]):.4f}Β° (~{abs(src.transform[0]) * 111:.1f} km)")βοΈ Cropping rasters to study area...
Processing: 100%|ββββββββββ| 19/19 [00:02<00:00, 7.82it/s]
β
Successfully cropped 19 rasters!
π Cropped raster dimensions: 418 Γ 378 pixels Resolution: 0.0083Β° (~0.9 km)5. π‘οΈ Visualize Environmental Variables
Section titled β5. π‘οΈ Visualize Environmental VariablesβLetβs visualize some key bioclimatic variables along with our occurrence data:
# Select key variables to visualizevariables_to_plot = [ ('CHELSA_bio1_1981-2010_V.2.1.tif', 'BIO1: Annual Mean Temperature', 'Β°C Γ 10', 'RdYlBu_r'), ('CHELSA_bio12_1981-2010_V.2.1.tif', 'BIO12: Annual Precipitation', 'mm', 'YlGnBu'), ('CHELSA_bio4_1981-2010_V.2.1.tif', 'BIO4: Temperature Seasonality', 'std Γ 100', 'Spectral_r'), ('CHELSA_bio15_1981-2010_V.2.1.tif', 'BIO15: Precipitation Seasonality', 'CV', 'BrBG')]
fig, axes = plt.subplots(2, 2, figsize=(12, 8), subplot_kw={'projection': ccrs.PlateCarree()})axes = axes.flatten()
for ax, (filename, title, unit, cmap) in zip(axes, variables_to_plot): filepath = os.path.join(output_folder, filename)
with rasterio.open(filepath) as src: data = src.read(1).astype(np.float32) extent_raster = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top] nodata = src.nodata
# Mask nodata values and any extreme values that could cause overflow if nodata is not None: data = np.ma.masked_where( (data == nodata) | (np.abs(data) > 1e10) | ~np.isfinite(data), data ) else: data = np.ma.masked_where((np.abs(data) > 1e10) | ~np.isfinite(data), data)
# Add features ax.add_feature(cfeature.COASTLINE, linewidth=0.5) ax.add_feature(cfeature.BORDERS, linestyle='--', linewidth=0.3)
# Plot raster im = ax.imshow(data, extent=extent_raster, origin='upper', cmap=cmap, transform=ccrs.PlateCarree())
# Overlay occurrences ax.scatter(df_filtered['decimalLongitude'], df_filtered['decimalLatitude'], c='black', s=3, alpha=0.3, transform=ccrs.PlateCarree())
# Colorbar cbar = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02) cbar.set_label(unit, fontsize=10)
# Gridlines gl = ax.gridlines(draw_labels=True, linewidth=0.3, alpha=0.5, linestyle='--') gl.top_labels = False gl.right_labels = False
ax.set_title(title, fontsize=12, fontweight='bold')
plt.suptitle('Key Bioclimatic Variables with Species Occurrences (black dots)', fontsize=16, fontweight='bold', y=1.02)plt.tight_layout()plt.show()
6. π Prepare Training Data
Section titled β6. π Prepare Training DataβNow weβll prepare the data for DeepMaxent by:
- Extracting environmental values at each occurrence location
- Aggregating occurrences by raster cell (multiple observations at the same location)
- Building the occurrence count matrix (y) where each entry represents the number of observations per species per location
6.1 Extract Environmental Values at Occurrence Points
Section titled β6.1 Extract Environmental Values at Occurrence Pointsβ# Get the raster resolution inforasters_dir = 'data/tutorial/cropped_rasters/'raster_files = sorted([f for f in os.listdir(rasters_dir) if f.endswith('.tif')])
# Get raster propertieswith rasterio.open(os.path.join(rasters_dir, raster_files[0])) as src: raster_resolution = abs(src.transform[0]) raster_transform = src.transform raster_crs = src.crs
print(f"π Raster resolution: {raster_resolution:.4f}Β° (~{raster_resolution * 111:.1f} km)")print(f"π Number of environmental variables: {len(raster_files)}")print(f"\nπ Variables:")for i, f in enumerate(raster_files, 1): print(f" {i:2d}. {f}")π Raster resolution: 0.0083Β° (~0.9 km)π Number of environmental variables: 19
π Variables: 1. CHELSA_bio10_1981-2010_V.2.1.tif 2. CHELSA_bio11_1981-2010_V.2.1.tif 3. CHELSA_bio12_1981-2010_V.2.1.tif 4. CHELSA_bio13_1981-2010_V.2.1.tif 5. CHELSA_bio14_1981-2010_V.2.1.tif 6. CHELSA_bio15_1981-2010_V.2.1.tif 7. CHELSA_bio16_1981-2010_V.2.1.tif 8. CHELSA_bio17_1981-2010_V.2.1.tif 9. CHELSA_bio18_1981-2010_V.2.1.tif 10. CHELSA_bio19_1981-2010_V.2.1.tif 11. CHELSA_bio1_1981-2010_V.2.1.tif 12. CHELSA_bio2_1981-2010_V.2.1.tif 13. CHELSA_bio3_1981-2010_V.2.1.tif 14. CHELSA_bio4_1981-2010_V.2.1.tif 15. CHELSA_bio5_1981-2010_V.2.1.tif 16. CHELSA_bio6_1981-2010_V.2.1.tif 17. CHELSA_bio7_1981-2010_V.2.1.tif 18. CHELSA_bio8_1981-2010_V.2.1.tif 19. CHELSA_bio9_1981-2010_V.2.1.tif6.2 Aggregate Occurrences by Raster Cell
Section titled β6.2 Aggregate Occurrences by Raster CellβMultiple species observations can occur at the same location. We need to: 1. Group observations by unique coordinates 2. Count occurrences per species at each location 3. This creates our occurrence count matrix (y)
# Aggregate observations by unique location# This groups all species observations at each coordinate pairdf_unique = df_filtered.groupby(['decimalLongitude', 'decimalLatitude']).agg({ 'species': list # Collect all species observed at this location}).reset_index()
# Get unique species list and create index mappingspecies_list = df_filtered['species'].unique()num_species = len(species_list)species_to_idx = {species: idx for idx, species in enumerate(species_list)}
print(f"π Unique locations: {len(df_unique):,}")print(f"π± Unique species: {num_species:,}")print(f"π From {len(df_filtered):,} individual observations")print(f"\nπ Aggregation ratio: {len(df_filtered) / len(df_unique):.1f} observations per location (avg)")π Unique locations: 1,040π± Unique species: 102π From 1,145 individual observations
π Aggregation ratio: 1.1 observations per location (avg)6.3 Build Training Tensors
Section titled β6.3 Build Training TensorsβNow we create: - X tensor (environmental features): shape
(n_locations, n_variables) - y tensor (occurrence counts): shape
(n_locations, n_species)
# Initialize tensorsnum_locations = len(df_unique)num_variables = len(raster_files)
X_tensor = torch.zeros((num_locations, num_variables), dtype=torch.float32)y_tensor = torch.zeros((num_locations, num_species), dtype=torch.float32)
# Build occurrence count matrix (y)print("π’ Building occurrence count matrix...")for idx, row in tqdm(df_unique.iterrows(), total=len(df_unique), desc="Processing locations"): species_at_location = row['species'] for sp in species_at_location: y_tensor[idx, species_to_idx[sp]] += 1
# Extract environmental values at each locationprint("\nπ‘οΈ Extracting environmental values...")coords = list(zip(df_unique['decimalLongitude'], df_unique['decimalLatitude']))
for var_idx, filename in enumerate(tqdm(raster_files, desc="Processing rasters")): filepath = os.path.join(rasters_dir, filename)
with rasterio.open(filepath) as src: nodata = src.nodata # Convert to float first to allow NaN assignment values = np.array([val[0] for val in src.sample(coords)], dtype=np.float32)
# Handle nodata values if nodata is not None: values[values == nodata] = np.nan
X_tensor[:, var_idx] = torch.tensor(values, dtype=torch.float32)
print(f"\nβ
Tensor creation complete!")print(f" X (environmental features): {X_tensor.shape}")print(f" y (occurrence counts): {y_tensor.shape}")π’ Building occurrence count matrix...
Processing locations: 100%|ββββββββββ| 1040/1040 [00:00<00:00, 10967.15it/s]
π‘οΈ Extracting environmental values...
Processing rasters: 100%|ββββββββββ| 19/19 [00:01<00:00, 18.11it/s]
β
Tensor creation complete! X (environmental features): torch.Size([1040, 19]) y (occurrence counts): torch.Size([1040, 102])7. π Summary Statistics & Data Quality Check
Section titled β7. π Summary Statistics & Data Quality CheckβLetβs examine the quality of our prepared data:
# Check for missing valuesnan_mask = torch.isnan(X_tensor)rows_with_nan = nan_mask.any(dim=1).sum().item()total_nan = nan_mask.sum().item()
print("π DATA SUMMARY")print("=" * 60)print(f"\nπ’ Tensor Shapes:")print(f" X (features): {X_tensor.shape} β {num_locations:,} locations Γ {num_variables} variables")print(f" y (occurrences): {y_tensor.shape} β {num_locations:,} locations Γ {num_species:,} species")
print(f"\nπ Feature Statistics:")for i, fname in enumerate(raster_files[:5]): var_name = fname.replace('wc2.1_10m_', '').replace('.tif', '') values = X_tensor[:, i] valid_values = values[~torch.isnan(values)] print(f" {var_name}: min={valid_values.min():.1f}, max={valid_values.max():.1f}, mean={valid_values.mean():.1f}")print(f" ... and {len(raster_files) - 5} more variables")
print(f"\nβ οΈ Missing Values:")print(f" Locations with NaN: {rows_with_nan:,} / {num_locations:,} ({100*rows_with_nan/num_locations:.1f}%)")print(f" Total NaN values: {total_nan:,}")
print(f"\nπ± Species Occurrence Statistics:")species_counts = y_tensor.sum(dim=0)print(f" Total occurrences in y: {y_tensor.sum().item():,.0f}")print(f" Species with data: {(species_counts > 0).sum().item():,} / {num_species:,}")print(f" Max occurrences per species: {species_counts.max().item():.0f}")print(f" Median occurrences per species: {torch.median(species_counts[species_counts > 0]).item():.0f}")π DATA SUMMARY============================================================
π’ Tensor Shapes: X (features): torch.Size([1040, 19]) β 1,040 locations Γ 19 variables y (occurrences): torch.Size([1040, 102]) β 1,040 locations Γ 102 species
π Feature Statistics: CHELSA_bio10_1981-2010_V.2.1: min=2796.0, max=3022.0, mean=2948.7 CHELSA_bio11_1981-2010_V.2.1: min=2782.0, max=2996.0, mean=2934.1 CHELSA_bio12_1981-2010_V.2.1: min=13697.0, max=65535.0, mean=38994.0 CHELSA_bio13_1981-2010_V.2.1: min=2885.0, max=11099.0, mean=5588.5 CHELSA_bio14_1981-2010_V.2.1: min=15.0, max=2696.0, mean=913.1 ... and 14 more variables
β οΈ Missing Values: Locations with NaN: 0 / 1,040 (0.0%) Total NaN values: 0
π± Species Occurrence Statistics: Total occurrences in y: 1,145 Species with data: 102 / 102 Max occurrences per species: 344 Median occurrences per species: 2# Visualize species occurrence distributionfig, axes = plt.subplots(1, 2, figsize=(8, 3))
# Distribution of occurrences per speciesax1 = axes[0]species_totals = y_tensor.sum(dim=0).numpy()species_totals_nonzero = species_totals[species_totals > 0]
ax1.hist(species_totals_nonzero, bins=50, color='#3498db', edgecolor='white', alpha=0.8)ax1.axvline(np.median(species_totals_nonzero), color='#e74c3c', linestyle='--', linewidth=2, label=f'Median: {np.median(species_totals_nonzero):.0f}')ax1.set_xlabel('Number of Occurrences', fontsize=12)ax1.set_ylabel('Number of Species', fontsize=12)ax1.set_title('Distribution of Occurrences per Species', fontsize=13, fontweight='bold')ax1.legend()ax1.set_yscale('log')ax1.spines['top'].set_visible(False)ax1.spines['right'].set_visible(False)
# Distribution of species richness per locationax2 = axes[1]richness_per_location = (y_tensor > 0).sum(dim=1).numpy()
ax2.hist(richness_per_location, bins=30, color='#2ecc71', edgecolor='white', alpha=0.8)ax2.axvline(np.median(richness_per_location), color='#e74c3c', linestyle='--', linewidth=2, label=f'Median: {np.median(richness_per_location):.0f}')ax2.set_xlabel('Number of Species', fontsize=12)ax2.set_ylabel('Number of Locations', fontsize=12)ax2.set_title('Species Richness per Location', fontsize=13, fontweight='bold')ax2.legend()ax2.spines['top'].set_visible(False)ax2.spines['right'].set_visible(False)
plt.tight_layout()plt.show()
# Correlation matrix of environmental variablesfig, ax = plt.subplots(figsize=(12, 10))
# Calculate correlation matrix (ignoring NaN)X_numpy = X_tensor.numpy()valid_mask = ~np.isnan(X_numpy).any(axis=1)corr_matrix = np.corrcoef(X_numpy[valid_mask].T)
# Variable namesvar_names = [f.replace('wc2.1_10m_', '').replace('.tif', '').upper() for f in raster_files]
# Plot heatmapim = ax.imshow(corr_matrix, cmap='RdBu_r', vmin=-1, vmax=1, aspect='auto')
# Add colorbarcbar = plt.colorbar(im, ax=ax, shrink=0.8)cbar.set_label('Correlation Coefficient', fontsize=11)
# Labelsax.set_xticks(range(len(var_names)))ax.set_yticks(range(len(var_names)))ax.set_xticklabels(var_names, rotation=45, ha='right', fontsize=9)ax.set_yticklabels(var_names, fontsize=9)
ax.set_title('Correlation Matrix of Bioclimatic Variables', fontsize=14, fontweight='bold', pad=15)
plt.tight_layout()plt.show()
print("\nπ‘ Note: Highly correlated variables (|r| > 0.8) may cause multicollinearity issues.")print(" Consider feature selection or PCA for model training.")
π‘ Note: Highly correlated variables (|r| > 0.8) may cause multicollinearity issues. Consider feature selection or PCA for model training.7.1 πΊοΈ Map of Species Richness
Section titled β7.1 πΊοΈ Map of Species RichnessβLetβs visualize the species richness (number of different species) at each location:
# Map species richnessfig = plt.figure(figsize=(8, 6))ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
# Calculate species richness per locationrichness = (y_tensor > 0).sum(dim=1).numpy()
# Map extentpadding = 0.5extent = [ df_unique['decimalLongitude'].min() - padding, df_unique['decimalLongitude'].max() + padding, df_unique['decimalLatitude'].min() - padding, df_unique['decimalLatitude'].max() + padding]ax.set_extent(extent, crs=ccrs.PlateCarree())
# Add featuresax.add_feature(cfeature.LAND, facecolor='#f5f5f5')ax.add_feature(cfeature.OCEAN, facecolor='#e6f3ff')ax.add_feature(cfeature.COASTLINE, linewidth=0.8)ax.add_feature(cfeature.BORDERS, linestyle='--', linewidth=0.5)
# Create colormapscatter = ax.scatter( df_unique['decimalLongitude'], df_unique['decimalLatitude'], c=richness, cmap='viridis', s=20 + richness * 5, # Size proportional to richness alpha=0.7, edgecolor='white', linewidth=0.3, transform=ccrs.PlateCarree())
# Colorbarcbar = plt.colorbar(scatter, ax=ax, shrink=0.7, pad=0.02)cbar.set_label('Number of Species', fontsize=11)
# Gridlinesgl = ax.gridlines(draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--')gl.top_labels = Falsegl.right_labels = False
ax.set_title('Species Richness per Location\n(Size proportional to richness)', fontsize=16, fontweight='bold', pad=20)
plt.tight_layout()plt.show()
π― Intermediate Summary
Section titled βπ― Intermediate SummaryβWe have successfully:
- β Loaded and explored GBIF biodiversity data from Costa Rica
- β Visualized species occurrences on interactive maps
- β Cropped environmental rasters (WorldClim bioclimatic variables) to the study area
- β
Built training tensors:
- X: Environmental features at each unique location
- y: Species occurrence counts (multi-label target)
8. π Spatial Block Split for Train/Validation
Section titled β8. π Spatial Block Split for Train/ValidationβTo properly evaluate our model, we need to split our data into training and validation sets. However, using random splits can lead to spatial autocorrelation issues where nearby points end up in both sets.
Instead, weβll use a spatial block split based on geographic coordinates, ensuring that training and validation data come from different geographic regions.
# Spatial Block Split# We'll divide the study area into blocks based on longitude and assign blocks to train/validation
# Get coordinates from df_uniquelongitudes = df_unique['decimalLongitude'].valueslatitudes = df_unique['decimalLatitude'].values
# Define the split based on longitude (roughly 80% train, 20% validation)# We'll use the western part for training and eastern part for validationlon_threshold = np.percentile(longitudes, 90)
# Create train/validation maskstrain_mask = longitudes < lon_thresholdval_mask = ~train_mask
print("π SPATIAL BLOCK SPLIT")print("=" * 50)print(f"π Longitude threshold: {lon_threshold:.4f}Β°")print(f"\nπ Split statistics:")print(f" Training set: {train_mask.sum():,} locations ({100*train_mask.mean():.1f}%)")print(f" Validation set: {val_mask.sum():,} locations ({100*val_mask.mean():.1f}%)")
# Split the tensorsX_train = X_tensor[train_mask]y_train = y_tensor[train_mask]X_val = X_tensor[val_mask]y_val = y_tensor[val_mask]
# Get coordinates for plottingtrain_coords = df_unique[train_mask][['decimalLongitude', 'decimalLatitude']].valuesval_coords = df_unique[val_mask][['decimalLongitude', 'decimalLatitude']].values
print(f"\nπ Tensor shapes:")print(f" X_train: {X_train.shape}")print(f" y_train: {y_train.shape}")print(f" X_val: {X_val.shape}")print(f" y_val: {y_val.shape}")π SPATIAL BLOCK SPLIT==================================================π Longitude threshold: -83.2931Β°
π Split statistics: Training set: 936 locations (90.0%) Validation set: 104 locations (10.0%)
π Tensor shapes: X_train: torch.Size([936, 19]) y_train: torch.Size([936, 102]) X_val: torch.Size([104, 19]) y_val: torch.Size([104, 102])# Visualize the spatial split on a mapfig = plt.figure(figsize=(8, 6))ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
# Map extentpadding = 0.5extent = [ df_unique['decimalLongitude'].min() - padding, df_unique['decimalLongitude'].max() + padding, df_unique['decimalLatitude'].min() - padding, df_unique['decimalLatitude'].max() + padding]ax.set_extent(extent, crs=ccrs.PlateCarree())
# Add map featuresax.add_feature(cfeature.LAND, facecolor='#f5f5f5')ax.add_feature(cfeature.OCEAN, facecolor='#e6f3ff')ax.add_feature(cfeature.COASTLINE, linewidth=0.8)ax.add_feature(cfeature.BORDERS, linestyle='--', linewidth=0.5)
# Plot training points (blue)ax.scatter(train_coords[:, 0], train_coords[:, 1], c='#3498db', s=15, alpha=0.6, label=f'Training (n={len(train_coords):,})', transform=ccrs.PlateCarree(), edgecolor='white', linewidth=0.2)
# Plot validation points (red)ax.scatter(val_coords[:, 0], val_coords[:, 1], c='#e74c3c', s=15, alpha=0.6, label=f'Validation (n={len(val_coords):,})', transform=ccrs.PlateCarree(), edgecolor='white', linewidth=0.2)
# Draw the split line using plot instead of axvline (works with cartopy projections)lat_min, lat_max = df_unique['decimalLatitude'].min() - padding, df_unique['decimalLatitude'].max() + paddingax.plot([lon_threshold, lon_threshold], [lat_min, lat_max], color='#2c3e50', linestyle='--', linewidth=2, transform=ccrs.PlateCarree(), label=f'Split boundary ({lon_threshold:.2f}Β°)')
# Gridlinesgl = ax.gridlines(draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--')gl.top_labels = Falsegl.right_labels = False
ax.set_title('patial Block Split: Training vs Validation Sets\n(Based on Longitude)', fontsize=14, fontweight='bold', pad=20)ax.legend(loc='lower left', fontsize=10)
plt.tight_layout()plt.show()
9. π§ DeepMaxent Model Training
Section titled β9. π§ DeepMaxent Model TrainingβNow weβll train the DeepMaxent model using the prepared training data. The DeepMaxent loss function implements the maximum entropy principle for species distribution modeling.
9.1 Data Preprocessing
Section titled β9.1 Data PreprocessingβBefore training, we need to: 1. Handle missing values (NaN) in the environmental features 2. Normalize the features using StandardScaler
# Handle NaN values - remove locations with missing environmental datatrain_nan_mask = torch.isnan(X_train).any(dim=1)val_nan_mask = torch.isnan(X_val).any(dim=1)
X_train_clean = X_train[~train_nan_mask]y_train_clean = y_train[~train_nan_mask]X_val_clean = X_val[~val_nan_mask]y_val_clean = y_val[~val_nan_mask]
print("π§Ή Removing locations with NaN values...")print(f" Training: {train_nan_mask.sum().item()} locations removed β {X_train_clean.shape[0]:,} remaining")print(f" Validation: {val_nan_mask.sum().item()} locations removed β {X_val_clean.shape[0]:,} remaining")
# Normalize features using StandardScalerscaler = StandardScaler()
# Fit on training data onlyX_train_np = X_train_clean.numpy()X_val_np = X_val_clean.numpy()
X_train_scaled = scaler.fit_transform(X_train_np)X_val_scaled = scaler.transform(X_val_np)
# Convert back to tensorsX_train_tensor = torch.tensor(X_train_scaled, dtype=torch.float32)y_train_tensor = y_train_cleanX_val_tensor = torch.tensor(X_val_scaled, dtype=torch.float32)y_val_tensor = y_val_clean
print(f"\nβ
Data preprocessing complete!")print(f" X_train_tensor: {X_train_tensor.shape}")print(f" y_train_tensor: {y_train_tensor.shape}")print(f" X_val_tensor: {X_val_tensor.shape}")print(f" y_val_tensor: {y_val_tensor.shape}")π§Ή Removing locations with NaN values... Training: 0 locations removed β 936 remaining Validation: 0 locations removed β 104 remaining
β
Data preprocessing complete! X_train_tensor: torch.Size([936, 19]) y_train_tensor: torch.Size([936, 102]) X_val_tensor: torch.Size([104, 19]) y_val_tensor: torch.Size([104, 102])9.2 Define Training Configuration and Model
Section titled β9.2 Define Training Configuration and Modelβ# Additional imports for trainingimport torch.optim as optimfrom torch.utils.data import TensorDataset, DataLoaderfrom sklearn.metrics import roc_auc_scoreimport copy
# Training configuration using a simple namespaceclass Args: def __init__(self): self.learning_rate = 0.0001 self.epoch = 100 self.hidden_nbr = 3 # Number of hidden layers self.weight_decay = 3e-4 # L2 regularization
args = Args()
# Set devicedevice = torch.device("cuda" if torch.cuda.is_available() else "cpu")print(f"π₯οΈ Using device: {device}")
# Model parametersinput_size = X_train_tensor.shape[1]output_size = y_train_tensor.shape[1]hidden_size = 250
print(f"\nπ§ Model Architecture:")print(f" Input size: {input_size} (environmental variables)")print(f" Hidden size: {hidden_size}")print(f" Output size: {output_size} (species)")print(f" Hidden layers: {args.hidden_nbr}")
print(f"\nβοΈ Training Configuration:")print(f" Learning rate: {args.learning_rate}")print(f" Epochs: {args.epoch}")print(f" Weight decay: {args.weight_decay}")print(f" Batch size: 250")π₯οΈ Using device: cuda
π§ Model Architecture: Input size: 19 (environmental variables) Hidden size: 250 Output size: 102 (species) Hidden layers: 3
βοΈ Training Configuration: Learning rate: 0.0001 Epochs: 100 Weight decay: 0.0003 Batch size: 2509.3 Training Loop with Validation AUC
Section titled β9.3 Training Loop with Validation AUCβWeβll implement the training loop with: - DeepMaxent loss for optimization - Validation AUC computed at each epoch to monitor performance - Best model selection based on validation loss
def compute_auc(model, X, y, device): """ Compute mean AUC across all species with sufficient data.
Args: model: trained DeepMaxent model X: input features tensor y: target occurrence tensor device: computation device
Returns: mean_auc: average AUC across species valid_aucs: list of AUC values for each valid species """ model.eval() with torch.no_grad(): X_dev = X.to(device) predictions = model(X_dev).cpu() # Apply softmax to get probabilities probs = torch.softmax(predictions, dim=0).numpy()
y_np = y.numpy()
# Convert to binary (presence/absence) y_binary = (y_np > 0).astype(int)
valid_aucs = [] for sp_idx in range(y_binary.shape[1]): # Only compute AUC if species has both presences and absences if y_binary[:, sp_idx].sum() > 0 and y_binary[:, sp_idx].sum() < len(y_binary): try: auc = roc_auc_score(y_binary[:, sp_idx], probs[:, sp_idx]) valid_aucs.append(auc) except: pass
mean_auc = np.mean(valid_aucs) if valid_aucs else 0.0 return mean_auc, valid_aucs
def train_deepmodel(X_train, y_train, X_val, y_val, args, hidden_size=250, device="cuda"): """ Train DeepMaxent model with validation monitoring.
Returns: dict with model, predictions, loss history, and AUC history """ # Create data loaders train_dataset = TensorDataset(X_train, y_train) train_loader = DataLoader(train_dataset, batch_size=250, shuffle=True)
# Initialize model and loss input_size = X_train.shape[1] output_size = y_train.shape[1]
model = deepmaxent_model(input_size, hidden_size, output_size, args.hidden_nbr) model = model.to(device)
criterion = deepmaxent_loss().to(device)
optimizer = optim.Adam( model.parameters(), lr=args.learning_rate, weight_decay=args.weight_decay )
# Training history train_losses = [] val_losses = [] train_aucs = [] val_aucs = []
best_val_loss = float('inf') best_model_state = None
print("π Starting training...") print("=" * 60)
for epoch in tqdm(range(args.epoch), desc="Training"): # Training phase model.train() total_train_loss = 0.0
for batch_X, batch_y in train_loader: batch_X, batch_y = batch_X.to(device), batch_y.to(device)
optimizer.zero_grad() outputs = model(batch_X) loss = criterion(outputs, batch_y) loss.backward() optimizer.step()
total_train_loss += loss.item()
avg_train_loss = total_train_loss / len(train_loader) train_losses.append(avg_train_loss)
# Validation phase model.eval() with torch.no_grad(): X_val_dev = X_val.to(device) y_val_dev = y_val.to(device) val_outputs = model(X_val_dev) val_loss = criterion(val_outputs, y_val_dev).item() val_losses.append(val_loss)
# Compute AUC every 10 epochs (to save time) if (epoch + 1) % 10 == 0 or epoch == 0: train_auc, _ = compute_auc(model, X_train, y_train, device) val_auc, _ = compute_auc(model, X_val, y_val, device) train_aucs.append((epoch, train_auc)) val_aucs.append((epoch, val_auc))
print(f" Epoch {epoch+1:3d} | Train Loss: {avg_train_loss:.4f} | Val Loss: {val_loss:.4f} | " f"Train AUC: {train_auc:.4f} | Val AUC: {val_auc:.4f}")
# Save best model if val_loss < best_val_loss: best_val_loss = val_loss best_model_state = copy.deepcopy(model.state_dict())
# Load best model model.load_state_dict(best_model_state)
# Final predictions model.eval() with torch.no_grad(): final_predictions = model(X_train.to(device)).cpu()
print("=" * 60) print(f" Training complete! Best validation loss: {best_val_loss:.4f}")
return { "model": model, "predictions": final_predictions, "train_losses": train_losses, "val_losses": val_losses, "train_aucs": train_aucs, "val_aucs": val_aucs, "best_val_loss": best_val_loss }# Train the model!results = train_deepmodel( X_train_tensor, y_train_tensor, X_val_tensor, y_val_tensor, args, hidden_size=hidden_size, device=device)π Starting training...============================================================
Training: 5%|β | 5/100 [00:00<00:13, 6.97it/s]
Epoch 1 | Train Loss: 0.0590 | Val Loss: 0.0469 | Train AUC: 0.5143 | Val AUC: 0.5141
Training: 17%|ββ | 17/100 [00:01<00:04, 19.31it/s]
Epoch 10 | Train Loss: 0.0556 | Val Loss: 0.0462 | Train AUC: 0.6787 | Val AUC: 0.5734
Training: 25%|βββ | 25/100 [00:01<00:03, 19.83it/s]
Epoch 20 | Train Loss: 0.0542 | Val Loss: 0.0455 | Train AUC: 0.7979 | Val AUC: 0.6052
Training: 35%|ββββ | 35/100 [00:02<00:03, 19.75it/s]
Epoch 30 | Train Loss: 0.0534 | Val Loss: 0.0450 | Train AUC: 0.8695 | Val AUC: 0.6386
Training: 46%|βββββ | 46/100 [00:02<00:02, 20.64it/s]
Epoch 40 | Train Loss: 0.0522 | Val Loss: 0.0446 | Train AUC: 0.8998 | Val AUC: 0.6507
Training: 53%|ββββββ | 53/100 [00:03<00:02, 18.09it/s]
Epoch 50 | Train Loss: 0.0518 | Val Loss: 0.0444 | Train AUC: 0.9122 | Val AUC: 0.6665
Training: 65%|βββββββ | 65/100 [00:03<00:01, 21.85it/s]
Epoch 60 | Train Loss: 0.0511 | Val Loss: 0.0442 | Train AUC: 0.9170 | Val AUC: 0.6782
Training: 76%|ββββββββ | 76/100 [00:04<00:01, 21.73it/s]
Epoch 70 | Train Loss: 0.0508 | Val Loss: 0.0441 | Train AUC: 0.9198 | Val AUC: 0.6746
Training: 84%|βββββββββ | 84/100 [00:04<00:00, 22.10it/s]
Epoch 80 | Train Loss: 0.0508 | Val Loss: 0.0441 | Train AUC: 0.9226 | Val AUC: 0.6751
Training: 97%|ββββββββββ| 97/100 [00:05<00:00, 24.29it/s]
Epoch 90 | Train Loss: 0.0503 | Val Loss: 0.0441 | Train AUC: 0.9254 | Val AUC: 0.6717
Training: 100%|ββββββββββ| 100/100 [00:05<00:00, 17.97it/s]
Epoch 100 | Train Loss: 0.0499 | Val Loss: 0.0441 | Train AUC: 0.9278 | Val AUC: 0.6617============================================================ Training complete! Best validation loss: 0.04419.4 Visualize Training Progress
Section titled β9.4 Visualize Training ProgressβLetβs visualize the training and validation loss curves, as well as the AUC evolution:
# Plot training curvesfig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Loss curvesax1 = axes[0]epochs = range(1, len(results['train_losses']) + 1)ax1.plot(epochs, results['train_losses'], 'b-', linewidth=2, label='Training Loss', alpha=0.8)ax1.plot(epochs, results['val_losses'], 'r-', linewidth=2, label='Validation Loss', alpha=0.8)ax1.set_xlabel('Epoch', fontsize=12)ax1.set_ylabel('DeepMaxent Loss', fontsize=12)ax1.set_title('Training and Validation Loss', fontsize=13, fontweight='bold')ax1.legend(fontsize=10)ax1.grid(True, alpha=0.3)ax1.spines['top'].set_visible(False)ax1.spines['right'].set_visible(False)
# AUC curvesax2 = axes[1]train_auc_epochs = [x[0] + 1 for x in results['train_aucs']]train_auc_values = [x[1] for x in results['train_aucs']]val_auc_epochs = [x[0] + 1 for x in results['val_aucs']]val_auc_values = [x[1] for x in results['val_aucs']]
ax2.plot(train_auc_epochs, train_auc_values, 'b-o', linewidth=2, markersize=6, label='Training AUC', alpha=0.8)ax2.plot(val_auc_epochs, val_auc_values, 'r-o', linewidth=2, markersize=6, label='Validation AUC', alpha=0.8)ax2.axhline(y=0.5, color='gray', linestyle='--', linewidth=1, label='Random (AUC=0.5)')ax2.set_xlabel('Epoch', fontsize=12)ax2.set_ylabel('Mean AUC', fontsize=12)ax2.set_title('AUC Evolution During Training', fontsize=13, fontweight='bold')ax2.legend(fontsize=10)ax2.grid(True, alpha=0.3)ax2.set_ylim([0.4, 1.0])ax2.spines['top'].set_visible(False)ax2.spines['right'].set_visible(False)
plt.tight_layout()plt.show()
# Print final metricsprint("\nπ FINAL METRICS")print("=" * 50)print(f" Final Training Loss: {results['train_losses'][-1]:.4f}")print(f" Final Validation Loss: {results['val_losses'][-1]:.4f}")print(f" Best Validation Loss: {results['best_val_loss']:.4f}")print(f"\n Final Training AUC: {train_auc_values[-1]:.4f}")print(f" Final Validation AUC: {val_auc_values[-1]:.4f}")
π FINAL METRICS================================================== Final Training Loss: 0.0499 Final Validation Loss: 0.0441 Best Validation Loss: 0.0441
Final Training AUC: 0.9278 Final Validation AUC: 0.66179.5 Detailed AUC Analysis per Species
Section titled β9.5 Detailed AUC Analysis per SpeciesβLetβs analyze the AUC distribution across species to understand model performance:
# Compute detailed AUC for validation setval_mean_auc, val_species_aucs = compute_auc(results['model'], X_val_tensor, y_val_tensor, device)
# Distribution of AUC valuesfig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Histogram of AUC valuesax1 = axes[0]ax1.hist(val_species_aucs, bins=30, color='#3498db', edgecolor='white', alpha=0.8)ax1.axvline(x=np.mean(val_species_aucs), color='#e74c3c', linestyle='--', linewidth=2, label=f'Mean AUC: {np.mean(val_species_aucs):.3f}')ax1.axvline(x=np.median(val_species_aucs), color='#27ae60', linestyle='--', linewidth=2, label=f'Median AUC: {np.median(val_species_aucs):.3f}')ax1.axvline(x=0.5, color='gray', linestyle=':', linewidth=2, label='Random (0.5)')ax1.set_xlabel('AUC Score', fontsize=12)ax1.set_ylabel('Number of Species', fontsize=12)ax1.set_title('Distribution of Validation AUC per Species', fontsize=13, fontweight='bold')ax1.legend(fontsize=10)ax1.spines['top'].set_visible(False)ax1.spines['right'].set_visible(False)
# Box plotax2 = axes[1]box = ax2.boxplot(val_species_aucs, vert=True, patch_artist=True)box['boxes'][0].set_facecolor('#3498db')box['boxes'][0].set_alpha(0.7)ax2.axhline(y=0.5, color='gray', linestyle='--', linewidth=1, label='Random (0.5)')ax2.set_ylabel('AUC Score', fontsize=12)ax2.set_title('AUC Distribution (Box Plot)', fontsize=13, fontweight='bold')ax2.set_xticklabels(['Validation Set'])ax2.legend(fontsize=10)ax2.spines['top'].set_visible(False)ax2.spines['right'].set_visible(False)
plt.tight_layout()plt.show()
# Summary statisticsprint("\nπ VALIDATION AUC SUMMARY")print("=" * 50)print(f" Number of species evaluated: {len(val_species_aucs)}")print(f" Mean AUC: {np.mean(val_species_aucs):.4f}")print(f" Median AUC: {np.median(val_species_aucs):.4f}")print(f" Std AUC: {np.std(val_species_aucs):.4f}")print(f" Min AUC: {np.min(val_species_aucs):.4f}")print(f" Max AUC: {np.max(val_species_aucs):.4f}")print(f"\n Species with AUC > 0.7: {sum(np.array(val_species_aucs) > 0.7)} ({100*sum(np.array(val_species_aucs) > 0.7)/len(val_species_aucs):.1f}%)")print(f" Species with AUC > 0.5: {sum(np.array(val_species_aucs) > 0.5)} ({100*sum(np.array(val_species_aucs) > 0.5)/len(val_species_aucs):.1f}%)")
π VALIDATION AUC SUMMARY================================================== Number of species evaluated: 34 Mean AUC: 0.6751 Median AUC: 0.7720 Std AUC: 0.2692 Min AUC: 0.0194 Max AUC: 0.9806
Species with AUC > 0.7: 20 (58.8%) Species with AUC > 0.5: 24 (70.6%)10. πΊοΈ Generate Species Suitability Maps
Section titled β10. πΊοΈ Generate Species Suitability MapsβNow that we have a trained model, weβll apply it to the entire study area to generate suitability maps for each species. This involves:
- Loading all bioclimatic rasters as a stack
- Applying the same preprocessing (StandardScaler) used during training
- Running the model on each pixel
- Converting outputs to suitability probabilities
# Load all rasters and stack them in the correct orderprint("π Loading rasters in training order...")print(f" Raster directory: {rasters_dir}")print(f" Number of variables: {len(raster_files)}")
# Load the first raster to get metadatawith rasterio.open(os.path.join(rasters_dir, raster_files[0])) as src: raster_height = src.height raster_width = src.width raster_transform_map = src.transform raster_crs_map = src.crs raster_bounds = src.bounds
print(f"\nπ Raster dimensions: {raster_width} Γ {raster_height} pixels")print(f" Total pixels: {raster_width * raster_height:,}")
# Stack all rasters in the same order as trainingraster_stack = np.zeros((len(raster_files), raster_height, raster_width), dtype=np.float32)
for i, filename in enumerate(tqdm(raster_files, desc="Loading rasters")): filepath = os.path.join(rasters_dir, filename) with rasterio.open(filepath) as src: data = src.read(1).astype(np.float32) nodata_val = src.nodata
# Replace nodata with NaN if nodata_val is not None: data[data == nodata_val] = np.nan # Also mask extreme values data[np.abs(data) > 1e10] = np.nan
raster_stack[i] = data
print(f"\nβ
Raster stack shape: {raster_stack.shape}")print(f" Order of variables:")for i, f in enumerate(raster_files): print(f" {i}: {f}")π Loading rasters in training order... Raster directory: data/tutorial/cropped_rasters/ Number of variables: 19
π Raster dimensions: 418 Γ 378 pixels Total pixels: 158,004
Loading rasters: 100%|ββββββββββ| 19/19 [00:00<00:00, 247.46it/s]
β
Raster stack shape: (19, 378, 418) Order of variables: 0: CHELSA_bio10_1981-2010_V.2.1.tif 1: CHELSA_bio11_1981-2010_V.2.1.tif 2: CHELSA_bio12_1981-2010_V.2.1.tif 3: CHELSA_bio13_1981-2010_V.2.1.tif 4: CHELSA_bio14_1981-2010_V.2.1.tif 5: CHELSA_bio15_1981-2010_V.2.1.tif 6: CHELSA_bio16_1981-2010_V.2.1.tif 7: CHELSA_bio17_1981-2010_V.2.1.tif 8: CHELSA_bio18_1981-2010_V.2.1.tif 9: CHELSA_bio19_1981-2010_V.2.1.tif 10: CHELSA_bio1_1981-2010_V.2.1.tif 11: CHELSA_bio2_1981-2010_V.2.1.tif 12: CHELSA_bio3_1981-2010_V.2.1.tif 13: CHELSA_bio4_1981-2010_V.2.1.tif 14: CHELSA_bio5_1981-2010_V.2.1.tif 15: CHELSA_bio6_1981-2010_V.2.1.tif 16: CHELSA_bio7_1981-2010_V.2.1.tif 17: CHELSA_bio8_1981-2010_V.2.1.tif 18: CHELSA_bio9_1981-2010_V.2.1.tif# Reshape raster stack for model prediction# From (n_variables, height, width) to (n_pixels, n_variables)n_pixels = raster_height * raster_width
# Reshape: (variables, height, width) -> (height*width, variables)X_raster = raster_stack.reshape(len(raster_files), -1).T # Shape: (n_pixels, n_variables)
print(f"π Reshaped for prediction: {X_raster.shape}")
# Create mask for valid pixels (no NaN in any variable)valid_pixel_mask = ~np.isnan(X_raster).any(axis=1)n_valid_pixels = valid_pixel_mask.sum()
print(f" Valid pixels: {n_valid_pixels:,} / {n_pixels:,} ({100*n_valid_pixels/n_pixels:.1f}%)")
# Extract only valid pixels for predictionX_valid = X_raster[valid_pixel_mask]
# Apply the same scaler used during trainingprint("\nπ Applying StandardScaler (same as training)...")X_valid_scaled = scaler.transform(X_valid)
# Convert to tensorX_valid_tensor = torch.tensor(X_valid_scaled, dtype=torch.float32)print(f" Tensor shape for prediction: {X_valid_tensor.shape}")π Reshaped for prediction: (158004, 19) Valid pixels: 158,004 / 158,004 (100.0%)
π Applying StandardScaler (same as training)... Tensor shape for prediction: torch.Size([158004, 19])# Run model prediction on the raster dataprint("π§ Running model prediction on raster pixels...")
model = results['model']model.eval()model = model.to(device)
# Predict in batches to avoid memory issuesbatch_size = 10000n_batches = (len(X_valid_tensor) + batch_size - 1) // batch_size
predictions_list = []
with torch.no_grad(): for i in tqdm(range(n_batches), desc="Predicting"): start_idx = i * batch_size end_idx = min((i + 1) * batch_size, len(X_valid_tensor))
batch = X_valid_tensor[start_idx:end_idx].to(device) batch_pred = model(batch).cpu() predictions_list.append(batch_pred)
# Concatenate all predictionspredictions_valid = torch.cat(predictions_list, dim=0)
# Apply softmax to get suitability probabilities (along spatial dimension)# This gives relative suitability across space for each speciessuitability_valid = torch.softmax(predictions_valid, dim=0).numpy()
print(f"\nβ
Predictions complete!")print(f" Suitability shape: {suitability_valid.shape}")print(f" (n_valid_pixels, n_species) = ({n_valid_pixels:,}, {num_species:,})")π§ Running model prediction on raster pixels...
Predicting: 100%|ββββββββββ| 16/16 [00:00<00:00, 176.81it/s]
β
Predictions complete! Suitability shape: (158004, 102) (n_valid_pixels, n_species) = (158,004, 102)# Reconstruct full suitability maps (including NaN pixels)print("πΊοΈ Reconstructing suitability maps...")
# Initialize full suitability array with NaNsuitability_maps = np.full((num_species, n_pixels), np.nan, dtype=np.float32)
# Fill in valid pixelssuitability_maps[:, valid_pixel_mask] = suitability_valid.T # Transpose to (species, pixels)
# Reshape to (n_species, height, width)suitability_maps = suitability_maps.reshape(num_species, raster_height, raster_width)
print(f"β
Suitability maps shape: {suitability_maps.shape}")print(f" (n_species, height, width) = ({num_species:,}, {raster_height}, {raster_width})")
# Create species index to name mappingidx_to_species = {idx: species for species, idx in species_to_idx.items()}
# Find top species by number of occurrences for visualizationspecies_occurrence_counts = y_tensor.sum(dim=0).numpy()top_species_indices = np.argsort(species_occurrence_counts)[::-1][:10]
print(f"\nπ Top 10 species by occurrence count:")for rank, sp_idx in enumerate(top_species_indices, 1): sp_name = idx_to_species[sp_idx] count = int(species_occurrence_counts[sp_idx]) print(f" {rank:2d}. {sp_name}: {count} occurrences")πΊοΈ Reconstructing suitability maps...β
Suitability maps shape: (102, 378, 418) (n_species, height, width) = (102, 378, 418)
π Top 10 species by occurrence count: 1. Coffea arabica: 344 occurrences 2. Puya dasylirioides: 106 occurrences 3. Hibiscus rosa-sinensis: 72 occurrences 4. Tectona grandis: 71 occurrences 5. Musa coccinea: 65 occurrences 6. Quercus insignis: 42 occurrences 7. Peperomia rotundifolia: 39 occurrences 8. Gunnera talamancana: 27 occurrences 9. Oxalis corniculata: 22 occurrences 10. Cedrela odorata: 21 occurrences10.1 Visualize Suitability Maps for Top Species
Section titled β10.1 Visualize Suitability Maps for Top SpeciesβLetβs visualize the predicted suitability maps for the most observed species:
# Plot suitability maps for top 4 speciesfig, axes = plt.subplots(2, 2, figsize=(12, 10), subplot_kw={'projection': ccrs.PlateCarree()})axes = axes.flatten()
# Raster extent for plottingextent_map = [raster_bounds.left, raster_bounds.right, raster_bounds.bottom, raster_bounds.top]
for ax, sp_idx in zip(axes, top_species_indices[:4]): sp_name = idx_to_species[sp_idx] suitability = suitability_maps[sp_idx]
# Mask NaN values suitability_masked = np.ma.masked_invalid(suitability)
# Normalize for better visualization (0-1 range based on this species) suit_min = np.nanmin(suitability) suit_max = np.nanmax(suitability) if suit_max > suit_min: suitability_norm = (suitability_masked - suit_min) / (suit_max - suit_min) else: suitability_norm = suitability_masked
# Set map style (no ocean/lakes) ax.set_facecolor('honeydew') oceans = cfeature.NaturalEarthFeature('physical', 'ocean', '10m', edgecolor='none', facecolor='lightblue') ax.add_feature(oceans, zorder=2) lakes = cfeature.NaturalEarthFeature('physical', 'lakes', '10m', edgecolor='none', facecolor='lightblue') ax.add_feature(lakes, zorder=2) ax.add_feature(cfeature.BORDERS, zorder=2) ax.add_feature(cfeature.COASTLINE, linewidth=0.5, zorder=2)
# Plot suitability map im = ax.imshow(suitability_norm, extent=extent_map, origin='upper', cmap='YlOrRd', transform=ccrs.PlateCarree(), vmin=0, vmax=1, zorder=1)
# Get occurrence points for this species sp_occurrences = y_tensor[:, sp_idx].numpy() has_occurrence = sp_occurrences > 0 if has_occurrence.any(): occ_lons = df_unique['decimalLongitude'].values[has_occurrence] occ_lats = df_unique['decimalLatitude'].values[has_occurrence] ax.scatter(occ_lons, occ_lats, c='blue', s=10, alpha=0.6, transform=ccrs.PlateCarree(), edgecolor='white', linewidth=0.3, label=f'Occurrences (n={has_occurrence.sum()})', zorder=3)
# Colorbar cbar = plt.colorbar(im, ax=ax, shrink=0.7, pad=0.02) cbar.set_label('Relative Suitability', fontsize=9)
# Gridlines gl = ax.gridlines(draw_labels=True, linewidth=0.3, alpha=0.5, linestyle='--') gl.top_labels = False gl.right_labels = False gl.xlabel_style = {'fontsize': 8} gl.ylabel_style = {'fontsize': 8}
# Title with species name in italic ax.set_title(f'$\it{{{sp_name.replace(" ", "\ ")}}}$\n({int(species_occurrence_counts[sp_idx])} occurrences)', fontsize=11, fontweight='bold') ax.legend(loc='lower left', fontsize=8)
plt.suptitle('Predicted Suitability Maps for Top 4 Species\n(Blue dots = observed occurrences)', fontsize=14, fontweight='bold', y=1.02)plt.tight_layout()plt.show()
10.2 Species Suitability Richness Prediction Map
Section titled β10.2 Species Suitability Richness Prediction MapβWe can also create a predicted species common suitability map by summing suitabilities across all species:
# Compute predicted a sum of suitability across species(sum of suitabilities)# This gives an indication of how many species are predicted to be suitable at each location
# Sum suitabilities across all speciespredicted_richness = np.nansum(suitability_maps, axis=0)predicted_richness_masked = np.ma.masked_invalid(predicted_richness)
# Plotfig = plt.figure(figsize=(12, 8))ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
# Set map style (no ocean/lakes visible over land)ax.set_facecolor('honeydew')oceans = cfeature.NaturalEarthFeature('physical', 'ocean', '10m', edgecolor='none', facecolor='lightblue')ax.add_feature(oceans, zorder=2)lakes = cfeature.NaturalEarthFeature('physical', 'lakes', '10m', edgecolor='none', facecolor='lightblue')ax.add_feature(lakes, zorder=2)ax.add_feature(cfeature.BORDERS, zorder=2)ax.add_feature(cfeature.COASTLINE, linewidth=0.8, zorder=2)
# Plot predicted richnessim = ax.imshow(predicted_richness_masked, extent=extent_map, origin='upper', cmap='Spectral_r', transform=ccrs.PlateCarree(), zorder=1)
# Colorbarcbar = plt.colorbar(im, ax=ax, shrink=0.7, pad=0.02)cbar.set_label('Sum of Suitability Scores', fontsize=11)
# Gridlinesgl = ax.gridlines(draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--')gl.top_labels = Falsegl.right_labels = False
ax.set_title('Sum of suitability across all species', fontsize=14, fontweight='bold', pad=20)
plt.tight_layout()plt.show()
print(f"Predicted Richness Statistics:")print(f" Min: {np.nanmin(predicted_richness):.4f}")print(f" Max: {np.nanmax(predicted_richness):.4f}")print(f" Mean: {np.nanmean(predicted_richness):.4f}")
Predicted Richness Statistics: Min: 0.0005 Max: 0.0029 Mean: 0.000610.3 Save Suitability Maps as GeoTIFF (Optional)
Section titled β10.3 Save Suitability Maps as GeoTIFF (Optional)βWe can export the suitability maps as GeoTIFF files for use in GIS software:
# Create output directory for suitability mapsoutput_suitability_dir = 'data/tutorial/suitability_maps'os.makedirs(output_suitability_dir, exist_ok=True)
# Save suitability maps for top 10 speciesprint("πΎ Saving suitability maps as GeoTIFF...")
for sp_idx in tqdm(top_species_indices[:10], desc="Saving"): sp_name = idx_to_species[sp_idx] # Clean species name for filename sp_name_clean = sp_name.replace(' ', '_').replace('.', '')
suitability = suitability_maps[sp_idx]
# Define output path output_path = os.path.join(output_suitability_dir, f'suitability_{sp_name_clean}.tif')
# Write GeoTIFF with rasterio.open( output_path, 'w', driver='GTiff', height=raster_height, width=raster_width, count=1, dtype=np.float32, crs=raster_crs_map, transform=raster_transform_map, nodata=np.nan ) as dst: dst.write(suitability, 1)
# Also save the species richness maprichness_path = os.path.join(output_suitability_dir, 'predicted_species_richness.tif')with rasterio.open( richness_path, 'w', driver='GTiff', height=raster_height, width=raster_width, count=1, dtype=np.float32, crs=raster_crs_map, transform=raster_transform_map, nodata=np.nan) as dst: dst.write(predicted_richness.astype(np.float32), 1)
print(f"\nβ
Saved {len(top_species_indices[:10]) + 1} GeoTIFF files to: {output_suitability_dir}")print(f" - 10 species suitability maps")print(f" - 1 predicted species richness map")πΎ Saving suitability maps as GeoTIFF...
Saving: 0%| | 0/10 [00:00<?, ?it/s]Saving: 100%|ββββββββββ| 10/10 [00:00<00:00, 36.44it/s]
β
Saved 11 GeoTIFF files to: data/tutorial/suitability_maps - 10 species suitability maps - 1 predicted species richness mapπ― Final Summary
Section titled βπ― Final SummaryβIn this tutorial, we have successfully:
- β Loaded and explored GBIF biodiversity data from Costa Rica
- β Visualized species occurrences on interactive maps
- β Cropped environmental rasters (WorldClim bioclimatic variables)
- β Built training tensors (X and y)
- β Implemented spatial block split for train/validation
- β Trained a DeepMaxent model with the custom loss function
- β Evaluated model performance using AUC metrics
- β Generated suitability maps by applying the model to rasters
- β Exported results as GeoTIFF files
π Key Results
Section titled βπ Key Resultsβ| Metric | Value |
|---|---|
| Number of species | See training output |
| Training samples | See training output |
| Validation AUC | See training output |
| Suitability maps generated | 10 species + richness map |
π Next Steps
Section titled βπ Next Stepsβ- Compare suitability predictions with independent validation data
- Generate future climate projections using climate scenarios
- Apply ensemble methods for uncertainty quantification
- Compare with MaxEnt or other SDM approaches
Tutorial created for the DeepMaxent project - Species Distribution Modeling with Deep Learning