feat: enhance training workflow with model metadata loading and refactor data handling
- Introduced a new activity to load model metadata from the model store. - Refactored training logic to utilize new model metadata and improved parameter handling. - Updated the `TrainModelParams` class to include additional fields for model configuration. - Replaced deprecated utility functions with a custom train-test split implementation. - Removed unused utility functions and cleaned up the data manager repository. - Adjusted experiment tracking to include model-specific metadata in notifications.
This commit is contained in:
@@ -23,11 +23,35 @@ from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||
|
||||
from model_manager.sientia.metrics import mae, mse, r2
|
||||
from model_manager.sientia.utils import split_train_test
|
||||
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||
|
||||
|
||||
def train_test_split(data: pd.DataFrame | pd.Series, train_size: float, random_state: int | None = None, shuffle: bool = True) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
# 1. Definir a semente (seed) para reprodutibilidade
|
||||
if random_state is not None:
|
||||
np.random.seed(random_state)
|
||||
|
||||
# 2. Gerar índices e embaralhar se necessário
|
||||
indices = np.arange(len(data))
|
||||
|
||||
if shuffle:
|
||||
np.random.shuffle(indices)
|
||||
|
||||
# 3. Calcular o ponto de corte (split point)
|
||||
# Cálculo: N_treino = tamanho_total * proporcao_treino
|
||||
n_train = int(len(data) * train_size)
|
||||
|
||||
# 4. Dividir os índices
|
||||
train_indices = indices[:n_train]
|
||||
test_indices = indices[n_train:]
|
||||
|
||||
# 5. Retornar os dados fatiados (funciona para DataFrame ou Series)
|
||||
if isinstance(data, (pd.DataFrame, pd.Series)):
|
||||
return data.iloc[train_indices], data.iloc[test_indices]
|
||||
|
||||
return data[train_indices], data[test_indices]
|
||||
|
||||
def _ensure_date_column_parsed(data: pd.DataFrame, params: TrainModelParams) -> pd.DataFrame:
|
||||
"""
|
||||
If date_column is set, parse the column as timezone-aware
|
||||
@@ -53,74 +77,6 @@ def _ensure_date_column_parsed(data: pd.DataFrame, params: TrainModelParams) ->
|
||||
return data
|
||||
|
||||
|
||||
def _single_variable_support_mask(
|
||||
data_view: pd.DataFrame,
|
||||
var_col: str,
|
||||
target_variable: str,
|
||||
config: dict,
|
||||
) -> np.ndarray | None:
|
||||
"""Compute keep mask for one variable's support lines; None if config is invalid or skipped."""
|
||||
if var_col not in data_view.columns:
|
||||
return None
|
||||
upper = config.get('upper_line') or config.get('upperLine')
|
||||
lower = config.get('lower_line') or config.get('lowerLine')
|
||||
if not upper or not lower:
|
||||
return None
|
||||
|
||||
x_vals = data_view[var_col].astype(float).to_numpy()
|
||||
y_vals = data_view[target_variable].astype(float).to_numpy()
|
||||
xmin, xmax = float(np.nanmin(x_vals)), float(np.nanmax(x_vals))
|
||||
ymin, ymax = float(np.nanmin(y_vals)), float(np.nanmax(y_vals))
|
||||
x_range = (xmax - xmin) if (xmax - xmin) != 0 else 1.0
|
||||
y_range = (ymax - ymin) if (ymax - ymin) != 0 else 1.0
|
||||
scale_ratio = y_range / x_range
|
||||
|
||||
b1 = float(upper.get('intercept', 0))
|
||||
deg1 = float(upper.get('angle', 0))
|
||||
b2 = float(lower.get('intercept', 0))
|
||||
deg2 = float(lower.get('angle', 0))
|
||||
m1 = np.tan(np.deg2rad(deg1)) * scale_ratio
|
||||
m2 = np.tan(np.deg2rad(deg2)) * scale_ratio
|
||||
y1 = m1 * x_vals + b1
|
||||
y2 = m2 * x_vals + b2
|
||||
lower_bound = np.minimum(y1, y2)
|
||||
upper_bound = np.maximum(y1, y2)
|
||||
return (y_vals >= lower_bound) & (y_vals <= upper_bound)
|
||||
|
||||
|
||||
def _apply_support_filters(
|
||||
data_view: pd.DataFrame,
|
||||
target_variable: str,
|
||||
support_filters: dict,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Keep only rows where (var, target) lies between the two guide lines for each variable.
|
||||
|
||||
For each variable in support_filters, the condition is lower(x_var) <= target <= upper(x_var),
|
||||
where lower/upper are the two lines (intercept + slope from angle, scaled by y_range/x_range).
|
||||
Global mask is AND across all variables. Matches DEMO logic in template_01.py.
|
||||
|
||||
Args:
|
||||
data_view: DataFrame after preprocessor transform.
|
||||
target_variable: Name of the target column (y axis).
|
||||
support_filters: Per-variable config with upper_line/lower_line, each {intercept, angle}.
|
||||
|
||||
Returns:
|
||||
data_view filtered to rows satisfying all variable conditions; unchanged if support_filters empty.
|
||||
"""
|
||||
if not support_filters or target_variable not in data_view.columns:
|
||||
return data_view
|
||||
|
||||
combined_keep_mask = np.ones(len(data_view), dtype=bool)
|
||||
n = len(data_view)
|
||||
for var_col, config in support_filters.items():
|
||||
keep_mask = _single_variable_support_mask(data_view, var_col, target_variable, config)
|
||||
if keep_mask is not None and len(keep_mask) == n:
|
||||
combined_keep_mask &= keep_mask
|
||||
|
||||
return data_view.loc[combined_keep_mask]
|
||||
|
||||
|
||||
class DataManagerRepository(SientiaMonitoring):
|
||||
"""
|
||||
Repository for data preparation in the training pipeline.
|
||||
@@ -193,17 +149,11 @@ class DataManagerRepository(SientiaMonitoring):
|
||||
train_df = _ensure_date_column_parsed(train_df, params)
|
||||
train_df = self._configure_datetime_index(train_df, params, metadata)
|
||||
|
||||
if params.support_filters:
|
||||
train_df = _apply_support_filters(
|
||||
train_df,
|
||||
params.target_variable,
|
||||
params.support_filters,
|
||||
)
|
||||
|
||||
if len(train_df) <= 0:
|
||||
raise ValueError('Training data view is empty after transformation')
|
||||
|
||||
# Explicit validation dataset path
|
||||
train_data = pd.DataFrame(train_df[params.variable_columns + [params.target_variable]])
|
||||
if validation_file_bytes is not None:
|
||||
try:
|
||||
val_df = pd.read_csv(
|
||||
@@ -220,28 +170,18 @@ class DataManagerRepository(SientiaMonitoring):
|
||||
val_df = _ensure_date_column_parsed(val_df, params)
|
||||
val_df = self._configure_datetime_index(val_df, params, metadata)
|
||||
|
||||
if params.support_filters:
|
||||
val_df = _apply_support_filters(
|
||||
val_df,
|
||||
params.target_variable,
|
||||
params.support_filters,
|
||||
)
|
||||
|
||||
if len(val_df) <= 0:
|
||||
raise ValueError('Validation data view is empty after transformation')
|
||||
|
||||
x_train = pd.DataFrame(train_df[params.variable_columns])
|
||||
y_train = pd.Series(train_df[params.target_variable])
|
||||
x_test = pd.DataFrame(val_df[params.variable_columns])
|
||||
y_test = pd.Series(val_df[params.target_variable])
|
||||
|
||||
val_data = pd.DataFrame(val_df[params.variable_columns + [params.target_variable]])
|
||||
else:
|
||||
# Fallback path: derive validation via train/test split from a single dataset.
|
||||
x_train, x_test, y_train, y_test = split_train_test(
|
||||
pd.DataFrame(train_df[params.variable_columns]),
|
||||
pd.Series(train_df[params.target_variable]),
|
||||
train_data, val_data = train_test_split(
|
||||
train_data,
|
||||
train_size=params.train_size / 100,
|
||||
shuffle=params.shuffle,
|
||||
random_state=42,
|
||||
random_state=params.random_state,
|
||||
)
|
||||
|
||||
self.info(
|
||||
@@ -251,59 +191,62 @@ class DataManagerRepository(SientiaMonitoring):
|
||||
|
||||
return TrainModelResult(
|
||||
params=params,
|
||||
x_train=x_train,
|
||||
x_test=x_test,
|
||||
y_train=y_train,
|
||||
y_test=y_test,
|
||||
train_data=train_data,
|
||||
val_data=val_data
|
||||
)
|
||||
|
||||
def _as_series(self, pred: pd.DataFrame | pd.Series) -> pd.Series:
|
||||
if isinstance(pred, pd.Series):
|
||||
return pred
|
||||
# If the wrapper returns a single-column DataFrame, take its first column.
|
||||
if pred.shape[1] == 1:
|
||||
return pred.iloc[:, 0]
|
||||
raise ValueError('y_pred/y_train_pred must be a Series or single-column DataFrame')
|
||||
|
||||
def compute_regression_metrics(
|
||||
self,
|
||||
params: TrainModelParams,
|
||||
tmr: TrainModelResult,
|
||||
) -> TrainModelResult:
|
||||
"""
|
||||
Compute regression metrics for training results.
|
||||
|
||||
This helper mirrors the previous TrainingRepository.after_train_calculation
|
||||
behavior, assuming that y_pred/y_train_pred are already on the correct scale
|
||||
for metric calculation (any scaling is handled inside the model wrapper).
|
||||
behavior, assuming that predictions (y_pred/y_train_pred) are already on the
|
||||
correct scale for metric calculation (any scaling is handled inside the
|
||||
model wrapper).
|
||||
|
||||
Args:
|
||||
params: Training parameters used during model training.
|
||||
tmr: Training result with y_train, y_test, y_train_pred and y_pred populated.
|
||||
tmr: Training result containing:
|
||||
- train_data/val_data DataFrames with a target column
|
||||
- y_train_pred/y_pred populated (model predictions for train/val)
|
||||
|
||||
Return:
|
||||
Updated TrainModelResult with mse_val, mae_val and r2_val fields populated.
|
||||
"""
|
||||
del params # unused for now, kept for possible future extensions
|
||||
if tmr.y_pred is None:
|
||||
raise ValueError('y_pred must be set before computing regression metrics')
|
||||
|
||||
tmr.x_train = tmr.x_train.sort_index()
|
||||
tmr.x_test = tmr.x_test.sort_index()
|
||||
tmr.y_train = tmr.y_train.sort_index()
|
||||
tmr.y_test = tmr.y_test.sort_index()
|
||||
params = tmr.params
|
||||
target = params.target_variable
|
||||
|
||||
if tmr.y_pred is not None:
|
||||
tmr.y_pred = tmr.y_pred.sort_index()
|
||||
if tmr.y_train_pred is not None:
|
||||
tmr.y_train_pred = tmr.y_train_pred.sort_index()
|
||||
# True values are expected to come from val_data.
|
||||
y_true_val = tmr.val_data[target]
|
||||
|
||||
y_pred_val = self._as_series(tmr.y_pred).sort_index()
|
||||
y_true_val = y_true_val.sort_index()
|
||||
|
||||
assert tmr.y_pred is not None, 'y_pred should be set at this point'
|
||||
# Align by index to avoid metric calculation errors if ordering differs.
|
||||
common_index = y_true_val.index.intersection(y_pred_val.index)
|
||||
y_true_val = y_true_val.loc[common_index]
|
||||
y_pred_val = y_pred_val.loc[common_index]
|
||||
|
||||
tmr.mse_val = round(
|
||||
mse(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)),
|
||||
2,
|
||||
)
|
||||
if len(y_true_val) == 0:
|
||||
raise ValueError('No overlapping indices between val_data and y_pred')
|
||||
|
||||
tmr.mae_val = round(
|
||||
mae(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)),
|
||||
2,
|
||||
)
|
||||
|
||||
tmr.r2_val = round(
|
||||
r2(tmr.y_test.astype(np.float64), tmr.y_pred.astype(np.float64)),
|
||||
2,
|
||||
)
|
||||
# Metrics helpers already round to 2 decimals.
|
||||
tmr.mse_val = mse(y_true_val, y_pred_val)
|
||||
tmr.mae_val = mae(y_true_val, y_pred_val)
|
||||
tmr.r2_val = r2(y_true_val, y_pred_val)
|
||||
|
||||
return tmr
|
||||
|
||||
@@ -324,8 +267,6 @@ class DataManagerRepository(SientiaMonitoring):
|
||||
'Data is None after load_data. '
|
||||
'Check file format, line separator and decimal separator.'
|
||||
)
|
||||
if not isinstance(data, pd.DataFrame):
|
||||
raise TypeError(f'Expected DataFrame, got {type(data).__name__}')
|
||||
|
||||
if isinstance(data.index, pd.DatetimeIndex):
|
||||
self.info('DataFrame already has DatetimeIndex', metadata)
|
||||
|
||||
Reference in New Issue
Block a user