# Copyright 2026, IBM Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Hyperparameter Loader for Tuned Methods
This module loads pre-tuned hyperparameters from JSON files and overrides
config defaults. Each dataset has its own tuning file with task-specific
parameters (node_classification, link_prediction, node_ranking).
"""
import json
import logging
from pathlib import Path
from typing import Dict, Optional, Any
from dataclasses import fields, replace
logger = logging.getLogger(__name__)
# Map method names to their hyperparameter keys in JSON files
METHOD_TO_HYPERPARAM_KEY = {
# 5 Baselines - direct mappings
'node2vec': 'node2vec',
'appnp': 'appnp',
'graphsage': 'graphsage',
'netmf': 'netmf',
'baseline_gcnmf': 'baseline_gcnmf',
# 10 Filter variants - map to baseline_filter
'baseline_filter_heat': 'baseline_filter_heat',
'baseline_filter_poly': 'baseline_filter_poly',
'filter_rwr_heat': 'baseline_filter_heat', # Use baseline_filter_heat tuning
'filter_rwr_poly': 'baseline_filter_poly', # Use baseline_filter_poly tuning
'filter_ctqw_heat': 'baseline_filter_heat', # Use baseline_filter_heat tuning
'filter_ctqw_poly': 'baseline_filter_poly', # Use baseline_filter_poly tuning
'filter_dtqw_heat': 'baseline_filter_heat', # Use baseline_filter_heat tuning
'filter_dtqw_poly': 'baseline_filter_poly', # Use baseline_filter_poly tuning
# 10 GAT variants - all map to gat_baseline
'gat_baseline': 'gat_baseline', # Map baseline to itself
'baseline_gat': 'gat_baseline',
'gat_heat': 'gat_baseline',
'gat_poly': 'gat_baseline',
'gat_rwr': 'gat_baseline',
'gat_ctqw_heat': 'gat_baseline',
'gat_ctqw_poly': 'gat_baseline',
'gat_dtqw_heat': 'gat_baseline',
'gat_dtqw_poly': 'gat_baseline',
'gat_rwr_heat': 'gat_baseline',
'gat_rwr_poly': 'gat_baseline',
# 10 GraphGPS variants - all map to graphgps_baseline
'graphgps_baseline': 'graphgps_baseline', # Map baseline to itself
'baseline_graphgps': 'graphgps_baseline',
'graphgps_heat': 'graphgps_baseline',
'graphgps_poly': 'graphgps_baseline',
'graphgps_rwr': 'graphgps_baseline',
'graphgps_ctqw_heat': 'graphgps_baseline',
'graphgps_ctqw_poly': 'graphgps_baseline',
'graphgps_dtqw_heat': 'graphgps_baseline',
'graphgps_dtqw_poly': 'graphgps_baseline',
'graphgps_rwr_heat': 'graphgps_baseline',
'graphgps_rwr_poly': 'graphgps_baseline',
# 3 SGNS (QuVINE walk-based methods)
'quvine_ctqw': 'quvine_ctqw',
'quvine_dtqw': 'quvine_dtqw',
'quvine_rwr': 'quvine_rwr',
}
[docs]
class HyperparameterLoader:
"""
Loads and manages tuned hyperparameters from JSON files.
Example JSON structure::
{
"node2vec": {
"node2vec": {
"node_classification": {"best_params": {...}, "best_score": 0.5},
"link_prediction": {"best_params": {...}, "best_score": 0.9},
"node_ranking": {"best_params": {...}, "best_score": 0.4}
}
},
...
}
"""
[docs]
def __init__(self, tuning_dir: Optional[str] = None, dataset_name: Optional[str] = None):
"""
Initialize hyperparameter loader.
Args:
tuning_dir: Directory containing tuning JSON files
dataset_name: Name of the dataset (e.g., 'scale_free', 'BioPlex3_autism')
"""
self.tuning_dir = Path(tuning_dir) if tuning_dir else None
self.dataset_name = dataset_name
self.hyperparams: Dict[str, Any] = {}
self.loaded = False
if self.tuning_dir and self.dataset_name:
self.load_hyperparameters()
[docs]
def load_hyperparameters(self) -> bool:
"""
Load hyperparameters from JSON file.
Tries multiple filename patterns:
1. {dataset_name}_tuning_by_task.json (new format)
2. {dataset_name}_aggregated.json (legacy format)
Returns:
True if loaded successfully, False otherwise
"""
if not self.tuning_dir or not self.dataset_name:
logger.warning("Tuning directory or dataset name not provided")
return False
# Try multiple filename patterns
filenames = [
f"{self.dataset_name}_tuning_by_task.json", # New format
f"{self.dataset_name}_aggregated.json", # Legacy format
]
for filename in filenames:
filepath = self.tuning_dir / filename
if filepath.exists():
try:
with open(filepath, 'r') as f:
self.hyperparams = json.load(f)
self.loaded = True
logger.info(f"Loaded hyperparameters from {filepath}")
return True
except Exception as e:
logger.error(f"Error loading hyperparameters from {filepath}: {e}")
continue
logger.warning(f"Tuning file not found for dataset '{self.dataset_name}' in {self.tuning_dir}")
logger.warning(f"Tried: {', '.join(filenames)}")
return False
[docs]
def get_method_params(
self,
method_name: str,
task: str = "node_classification"
) -> Optional[Dict[str, Any]]:
"""
Get tuned parameters for a method and task.
Args:
method_name: Name of the method (e.g., 'node2vec', 'gat_ctqw_heat')
task: Task type ('node_classification', 'link_prediction', 'node_ranking')
Returns:
Dictionary of best parameters, or None if not found
"""
if not self.loaded:
return None
# Map method name to hyperparameter key
hyperparam_key = METHOD_TO_HYPERPARAM_KEY.get(method_name)
if not hyperparam_key:
logger.debug(f"No hyperparameter mapping for method: {method_name}")
return None
# Navigate JSON structure: hyperparams[key][task]['best_params']
try:
method_data = self.hyperparams.get(hyperparam_key, {})
task_data = method_data.get(task, {})
best_params = task_data.get('best_params')
if best_params:
logger.debug(f"Found tuned params for {method_name} ({task})")
return best_params
else:
logger.debug(f"No tuned params for {method_name} ({task})")
return None
except Exception as e:
logger.warning(f"Error extracting params for {method_name}: {e}")
return None
[docs]
def override_config(self, config, method_name: str, task: str = "node_classification"):
"""
Override config object with tuned hyperparameters.
Args:
config: Config dataclass object
method_name: Name of the method
task: Task type
Returns:
Updated config object (new instance via dataclass replace)
"""
tuned_params = self.get_method_params(method_name, task)
if not tuned_params:
return config
# Map JSON parameter names to config attribute names
param_mapping = self._get_param_mapping(method_name)
# Build kwargs for dataclass replace
update_kwargs = {}
for json_key, config_attr in param_mapping.items():
if json_key in tuned_params:
value = tuned_params[json_key]
# Handle string values that should be floats
if isinstance(value, str) and 'e-' in value:
value = float(value)
update_kwargs[config_attr] = value
valid_fields = {f.name for f in fields(config)}
# Dimension field name varies across configs: node2vec/appnp/graphsage/netmf
# use `dimensions`, while filter/gcnmf/gat/graphgps/quvine use `embedding_dim`.
# Route the tuned `embedding_dim` to whichever this config actually has so the
# tuned dimension is applied rather than dropped.
if 'embedding_dim' in tuned_params and 'embedding_dim' not in valid_fields and 'dimensions' in valid_fields:
update_kwargs.pop('embedding_dim', None)
update_kwargs['dimensions'] = tuned_params['embedding_dim']
# Drop any param the config doesn't accept (e.g. a method-agnostic mapping
# lists `epochs` but Node2VecConfig has none) so replace() never crashes.
dropped = {k: v for k, v in update_kwargs.items() if k not in valid_fields}
if dropped:
logger.warning(
f"Dropping {len(dropped)} tuned param(s) not on {type(config).__name__}: "
f"{sorted(dropped)}"
)
update_kwargs = {k: v for k, v in update_kwargs.items() if k in valid_fields}
if update_kwargs:
logger.info(f"Overriding {len(update_kwargs)} params for {method_name}")
return replace(config, **update_kwargs)
return config
def _get_param_mapping(self, method_name: str) -> Dict[str, str]:
"""
Get mapping from JSON parameter names to config attribute names.
Args:
method_name: Name of the method
Returns:
Dictionary mapping JSON keys to config attributes
"""
# Common mappings
common = {
'embedding_dim': 'embedding_dim',
'hidden_dim': 'hidden_dim',
'n_layers': 'n_layers',
'learning_rate': 'lr',
'weight_decay': 'weight_decay',
'epochs': 'epochs',
'dropout': 'dropout',
}
# Method-specific mappings
if method_name == 'node2vec':
return {
**common,
'walk_length': 'walk_length',
'num_walks': 'num_walks',
'p': 'p',
'q': 'q',
'window_size': 'window',
'negative_samples': 'min_count',
}
elif method_name == 'appnp':
return {
**common,
'alpha': 'alpha',
'k_hops': 'K',
}
elif method_name == 'graphsage':
return {
**common,
'aggregator': 'aggregator', # May need custom handling
'batch_size': 'batch_size', # May need custom handling
}
elif 'filter' in method_name:
return {
'embedding_dim': 'embedding_dim',
'tau': 't',
'filter_order': 'K',
'alpha': 'alpha', # For poly filter
}
elif 'gat' in method_name or 'graphgps' in method_name:
# These have nested configs, handle in override_config
return {
'embedding_dim': 'embedding_dim',
'hidden_dim': 'hidden_dim', # Will go to model config
'n_layers': 'n_layers', # Will go to model config
'n_heads': 'heads', # Will go to model config
'dropout': 'dropout', # Will go to model config
'attn_dropout': 'attention_dropout', # Will go to model config
'learning_rate': 'lr', # Will go to train config
'weight_decay': 'weight_decay', # Will go to train config
'epochs': 'epochs', # Will go to train config
}
elif 'gcnmf' in method_name:
return {
**common,
'mf_dim': 'mf_dim',
}
elif 'netmf' in method_name:
return {
'embedding_dim': 'dimensions',
'window_size': 'window_size',
'rank': 'rank',
'negative_samples': 'negative',
}
# Default: return common mappings
return common
[docs]
def override_nested_config(self, config, method_name: str, task: str = "node_classification"):
"""
Override nested config objects (for GAT/GraphGPS with model and train configs).
Args:
config: Config dataclass with nested model and train configs
method_name: Name of the method
task: Task type
Returns:
Updated config object
"""
tuned_params = self.get_method_params(method_name, task)
if not tuned_params:
return config
# Update model config
model_updates = {}
if 'hidden_dim' in tuned_params:
model_updates['hidden_dim'] = tuned_params['hidden_dim']
if 'n_layers' in tuned_params:
model_updates['num_layers'] = tuned_params['n_layers']
if 'n_heads' in tuned_params:
model_updates['heads'] = tuned_params['n_heads']
if 'dropout' in tuned_params:
model_updates['dropout'] = tuned_params['dropout']
if 'attn_dropout' in tuned_params:
if hasattr(config.model, 'attention_dropout'):
model_updates['attention_dropout'] = tuned_params['attn_dropout']
elif hasattr(config.model, 'attn_dropout'):
model_updates['attn_dropout'] = tuned_params['attn_dropout']
# Update train config
train_updates = {}
if 'learning_rate' in tuned_params:
train_updates['lr'] = tuned_params['learning_rate']
if 'weight_decay' in tuned_params:
value = tuned_params['weight_decay']
if isinstance(value, str):
value = float(value)
train_updates['weight_decay'] = value
if 'epochs' in tuned_params:
train_updates['epochs'] = tuned_params['epochs']
# Update top-level config
top_updates = {}
if 'embedding_dim' in tuned_params:
top_updates['embedding_dim'] = tuned_params['embedding_dim']
# Also update model output_dim
model_updates['output_dim'] = tuned_params['embedding_dim']
# Apply updates
new_config = config
if model_updates:
new_config = replace(new_config, model=replace(config.model, **model_updates))
if train_updates:
new_config = replace(new_config, train=replace(new_config.train, **train_updates))
if top_updates:
new_config = replace(new_config, **top_updates)
if model_updates or train_updates or top_updates:
logger.info(f"Overriding nested config for {method_name}: "
f"{len(model_updates)} model, {len(train_updates)} train, {len(top_updates)} top")
return new_config
# Global loader instance (can be set by pipeline)
_global_loader: Optional[HyperparameterLoader] = None
[docs]
def set_global_loader(loader: HyperparameterLoader):
"""Set the global hyperparameter loader."""
global _global_loader
_global_loader = loader
[docs]
def get_global_loader() -> Optional[HyperparameterLoader]:
"""Get the global hyperparameter loader."""
return _global_loader
[docs]
def load_and_override_config(config, method_name: str, task: str = "node_classification"):
"""
Convenience function to override config with tuned hyperparameters.
Args:
config: Config dataclass object
method_name: Name of the method
task: Task type
Returns:
Updated config object
"""
loader = get_global_loader()
if not loader:
return config
# Check if config has nested structure (GAT/GraphGPS)
if hasattr(config, 'model') and hasattr(config, 'train'):
return loader.override_nested_config(config, method_name, task)
else:
return loader.override_config(config, method_name, task)