Datavision / backend /ml /model_persistence.py
DataVision CI/CD Bot
release: clean production build for HuggingFace Space
09801ca
Raw
History Blame Contribute Delete
14.1 kB
"""
πŸ—„οΈ MODEL PERSISTENCE MANAGER - Per-User AutoML Model Storage
==============================================================
Provides persistent storage for trained ML models with:
- Per-user model isolation
- Model versioning
- Metadata tracking (training date, metrics, features)
- Model listing and management
- Auto-save after training
- Auto-load for predictions
"""
import os
import pickle
import json
import shutil
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
logger = logging.getLogger(__name__)
# Base storage path
MODELS_STORAGE_PATH = Path("./storage/models")
class ModelMetadata:
"""Metadata for a saved model"""
def __init__(
self,
model_id: str,
user_id: str,
model_name: str,
task_type: str,
target_column: str,
feature_columns: List[str],
metrics: Dict[str, float],
training_date: str,
dataset_info: Dict[str, Any],
version: int = 1,
is_active: bool = True
):
self.model_id = model_id
self.user_id = user_id
self.model_name = model_name
self.task_type = task_type
self.target_column = target_column
self.feature_columns = feature_columns
self.metrics = metrics
self.training_date = training_date
self.dataset_info = dataset_info
self.version = version
self.is_active = is_active
def to_dict(self) -> Dict:
return {
"model_id": self.model_id,
"user_id": self.user_id,
"model_name": self.model_name,
"task_type": self.task_type,
"target_column": self.target_column,
"feature_columns": self.feature_columns,
"metrics": self.metrics,
"training_date": self.training_date,
"dataset_info": self.dataset_info,
"version": self.version,
"is_active": self.is_active
}
@classmethod
def from_dict(cls, data: Dict) -> 'ModelMetadata':
return cls(**data)
class ModelPersistenceManager:
"""
πŸ—„οΈ Manages persistent storage of ML models per user.
Directory Structure:
./storage/models/
└── {user_id}/
β”œβ”€β”€ active_model.pkl # Currently active model
β”œβ”€β”€ active_metadata.json # Metadata for active model
└── history/
β”œβ”€β”€ model_v1.pkl
β”œβ”€β”€ model_v1_metadata.json
β”œβ”€β”€ model_v2.pkl
└── model_v2_metadata.json
"""
def __init__(self, base_path: Path = MODELS_STORAGE_PATH):
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
logger.info(f"πŸ“ Model Persistence Manager initialized at: {self.base_path}")
def _get_user_dir(self, user_id: str) -> Path:
"""Get user-specific model directory"""
user_dir = self.base_path / user_id
user_dir.mkdir(parents=True, exist_ok=True)
return user_dir
def _get_history_dir(self, user_id: str) -> Path:
"""Get user's model history directory"""
history_dir = self._get_user_dir(user_id) / "history"
history_dir.mkdir(parents=True, exist_ok=True)
return history_dir
def save_model(
self,
user_id: str,
engine_state: Dict[str, Any],
model_name: str,
task_type: str,
target_column: str,
feature_columns: List[str],
metrics: Dict[str, float],
dataset_info: Dict[str, Any]
) -> ModelMetadata:
"""
Save a trained model for a user.
Args:
user_id: User identifier
engine_state: Complete state of the ML engine (model, encoders, scalers, etc.)
model_name: Name of the best model
task_type: classification/regression
target_column: Target column name
feature_columns: List of feature column names
metrics: Model performance metrics
dataset_info: Info about the training dataset
Returns:
ModelMetadata object
"""
try:
user_dir = self._get_user_dir(user_id)
history_dir = self._get_history_dir(user_id)
# Get next version number
version = self._get_next_version(user_id)
# Generate model ID
model_id = f"{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_v{version}"
# Create metadata
metadata = ModelMetadata(
model_id=model_id,
user_id=user_id,
model_name=model_name,
task_type=task_type,
target_column=target_column,
feature_columns=feature_columns,
metrics=metrics,
training_date=datetime.now().isoformat(),
dataset_info=dataset_info,
version=version,
is_active=True
)
# Archive current active model if exists
active_model_path = user_dir / "active_model.pkl"
if active_model_path.exists():
old_version = version - 1
if old_version > 0:
shutil.copy(
active_model_path,
history_dir / f"model_v{old_version}.pkl"
)
old_meta_path = user_dir / "active_metadata.json"
if old_meta_path.exists():
shutil.copy(
old_meta_path,
history_dir / f"model_v{old_version}_metadata.json"
)
# Save new model as active
with open(active_model_path, 'wb') as f:
pickle.dump(engine_state, f)
# Save metadata
with open(user_dir / "active_metadata.json", 'w') as f:
json.dump(metadata.to_dict(), f, indent=2, default=str)
logger.info(f"βœ… Model saved for user {user_id}: {model_name} (v{version})")
logger.info(f" πŸ“Š Metrics: {metrics}")
return metadata
except Exception as e:
logger.error(f"❌ Failed to save model for {user_id}: {e}")
raise
def save_charts(self, user_id: str, charts: Dict[str, str]) -> bool:
"""Save generated charts for persistent access"""
try:
user_dir = self._get_user_dir(user_id)
charts_path = user_dir / "active_charts.json"
# Save new charts
with open(charts_path, 'w') as f:
json.dump(charts, f)
return True
except Exception as e:
logger.error(f"❌ Failed to save charts for {user_id}: {e}")
return False
def get_charts(self, user_id: str) -> Dict[str, str]:
"""Get saved charts for a user"""
try:
user_dir = self._get_user_dir(user_id)
charts_path = user_dir / "active_charts.json"
if not charts_path.exists():
return {}
with open(charts_path, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"❌ Failed to load charts for {user_id}: {e}")
return {}
def load_model(self, user_id: str, version: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""
Load a saved model for a user.
Args:
user_id: User identifier
version: Specific version to load (None = active model)
Returns:
Engine state dict or None if not found
"""
try:
user_dir = self._get_user_dir(user_id)
if version is None:
# Load active model
model_path = user_dir / "active_model.pkl"
else:
# Load specific version from history
model_path = self._get_history_dir(user_id) / f"model_v{version}.pkl"
if not model_path.exists():
logger.warning(f"⚠️ No model found for user {user_id}")
return None
with open(model_path, 'rb') as f:
engine_state = pickle.load(f)
logger.info(f"βœ… Loaded model for user {user_id}")
return engine_state
except Exception as e:
logger.error(f"❌ Failed to load model for {user_id}: {e}")
return None
def get_metadata(self, user_id: str, version: Optional[int] = None) -> Optional[ModelMetadata]:
"""Get metadata for a user's model"""
try:
user_dir = self._get_user_dir(user_id)
if version is None:
meta_path = user_dir / "active_metadata.json"
else:
meta_path = self._get_history_dir(user_id) / f"model_v{version}_metadata.json"
if not meta_path.exists():
return None
with open(meta_path, 'r') as f:
data = json.load(f)
return ModelMetadata.from_dict(data)
except Exception as e:
logger.error(f"❌ Failed to load metadata for {user_id}: {e}")
return None
def list_models(self, user_id: str) -> List[ModelMetadata]:
"""List all models for a user (active + history)"""
models = []
try:
# Get active model
active_meta = self.get_metadata(user_id)
if active_meta:
models.append(active_meta)
# Get historical models
history_dir = self._get_history_dir(user_id)
for meta_file in sorted(history_dir.glob("model_v*_metadata.json")):
with open(meta_file, 'r') as f:
data = json.load(f)
data['is_active'] = False
models.append(ModelMetadata.from_dict(data))
# Sort by version (newest first)
models.sort(key=lambda m: m.version, reverse=True)
except Exception as e:
logger.error(f"❌ Failed to list models for {user_id}: {e}")
return models
def delete_model(self, user_id: str, version: Optional[int] = None) -> bool:
"""Delete a specific model version or all models for a user"""
try:
if version is None:
# Delete all models for user
user_dir = self._get_user_dir(user_id)
if user_dir.exists():
shutil.rmtree(user_dir)
logger.info(f"πŸ—‘οΈ Deleted all models for user {user_id}")
return True
else:
# Delete specific version
history_dir = self._get_history_dir(user_id)
model_path = history_dir / f"model_v{version}.pkl"
meta_path = history_dir / f"model_v{version}_metadata.json"
deleted = False
if model_path.exists():
model_path.unlink()
deleted = True
if meta_path.exists():
meta_path.unlink()
deleted = True
if deleted:
logger.info(f"πŸ—‘οΈ Deleted model v{version} for user {user_id}")
return deleted
except Exception as e:
logger.error(f"❌ Failed to delete model: {e}")
return False
def rollback_to_version(self, user_id: str, version: int) -> bool:
"""Rollback to a previous model version"""
try:
user_dir = self._get_user_dir(user_id)
history_dir = self._get_history_dir(user_id)
# Check if version exists
old_model = history_dir / f"model_v{version}.pkl"
old_meta = history_dir / f"model_v{version}_metadata.json"
if not old_model.exists():
logger.warning(f"⚠️ Version {version} not found for user {user_id}")
return False
# Copy historical version to active
shutil.copy(old_model, user_dir / "active_model.pkl")
if old_meta.exists():
shutil.copy(old_meta, user_dir / "active_metadata.json")
logger.info(f"βœ… Rolled back to v{version} for user {user_id}")
return True
except Exception as e:
logger.error(f"❌ Failed to rollback: {e}")
return False
def _get_next_version(self, user_id: str) -> int:
"""Get the next version number for a user"""
metadata = self.get_metadata(user_id)
if metadata:
return metadata.version + 1
return 1
def has_model(self, user_id: str) -> bool:
"""Check if user has a trained model"""
user_dir = self._get_user_dir(user_id)
return (user_dir / "active_model.pkl").exists()
def get_all_users_with_models(self) -> List[str]:
"""Get list of all user IDs that have trained models"""
users = []
if self.base_path.exists():
for user_dir in self.base_path.iterdir():
if user_dir.is_dir() and (user_dir / "active_model.pkl").exists():
users.append(user_dir.name)
return users
# Global instance
model_persistence = ModelPersistenceManager()
def get_model_persistence_manager() -> ModelPersistenceManager:
"""Get the global ModelPersistenceManager instance"""
return model_persistence