feat: require date_column in training parameters and update documentation
- Made `date_column` a required field in `TrainModelParams`, ensuring it must be present in the input data. - Updated related documentation in `input-sample.md`, `README.md`, and various test scenarios to reflect the change in requirement. - Adjusted the handling of `date_format` to default to `yyyy-MM-dd HH:mm:ss` if omitted, enhancing usability. - Refined test scenarios to include new examples and ensure compliance with the updated parameter structure. These changes improve the robustness of the model training workflow and clarify the expectations for input data.
This commit is contained in:
@@ -18,8 +18,8 @@ def _minimal_params_dict():
|
||||
'file_name': 'f.csv',
|
||||
'line_separator': '\n',
|
||||
'decimal_separator': '.',
|
||||
'date_column': None,
|
||||
'date_format': None,
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from model_manager.utils.models.train_model_params import (
|
||||
DEFAULT_TRAIN_DATE_FORMAT,
|
||||
TrainModelParams,
|
||||
validate_frontend_date_format,
|
||||
)
|
||||
@@ -27,8 +28,8 @@ def valid_train_params_dict(minimal_model_metadata) -> dict:
|
||||
'file_name': 'test-file.csv',
|
||||
'line_separator': ',',
|
||||
'decimal_separator': '.',
|
||||
'date_column': None,
|
||||
'date_format': None,
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
@@ -52,10 +53,47 @@ def test_from_dict_success(valid_train_params_dict):
|
||||
assert params.target_variable == 'target'
|
||||
assert params.bucket_name == 'test-bucket'
|
||||
assert params.experiment_run_id == 1
|
||||
assert params.experiment_name == 'Linear Regression_experiment'
|
||||
assert params.experiment_name == 'Linear Regression'
|
||||
assert params.model_metadata is valid_train_params_dict['model_metadata']
|
||||
|
||||
|
||||
def test_from_dict_date_format_omitted_uses_default(valid_train_params_dict):
|
||||
"""Missing date_format defaults to DEFAULT_TRAIN_DATE_FORMAT."""
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
del d['date_format']
|
||||
params = TrainModelParams.from_dict(d)
|
||||
assert params.date_format == DEFAULT_TRAIN_DATE_FORMAT
|
||||
|
||||
|
||||
def test_from_dict_date_format_blank_uses_default(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['date_format'] = ' '
|
||||
params = TrainModelParams.from_dict(d)
|
||||
assert params.date_format == DEFAULT_TRAIN_DATE_FORMAT
|
||||
|
||||
|
||||
def test_from_dict_superfluous_date_column_camel_key_is_ignored(valid_train_params_dict):
|
||||
"""Only snake_case keys are read; dateColumn does not populate date_column."""
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['dateColumn'] = 'wrong_name'
|
||||
params = TrainModelParams.from_dict(d)
|
||||
assert params.date_column == 'timestamp'
|
||||
|
||||
|
||||
def test_from_dict_missing_date_column_raises(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
del d['date_column']
|
||||
with pytest.raises(ValueError, match='date_column is required'):
|
||||
TrainModelParams.from_dict(d)
|
||||
|
||||
|
||||
def test_from_dict_date_format_non_string_raises(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['date_format'] = 12345
|
||||
with pytest.raises(TypeError, match='date_format must be a string'):
|
||||
TrainModelParams.from_dict(d)
|
||||
|
||||
|
||||
def test_from_dict_coerces_experiment_run_id_string(valid_train_params_dict):
|
||||
"""Numeric string experiment_run_id is coerced to int."""
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
@@ -131,6 +169,14 @@ def test_validate_business_rules_empty_target(valid_train_params_dict):
|
||||
params.validate_business_rules()
|
||||
|
||||
|
||||
def test_validate_business_rules_whitespace_date_column(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
d['date_column'] = ' '
|
||||
params = TrainModelParams.from_dict(d)
|
||||
with pytest.raises(ValueError, match='date_column cannot be empty'):
|
||||
params.validate_business_rules()
|
||||
|
||||
|
||||
def test_from_dict_missing_required_key(valid_train_params_dict):
|
||||
d = copy.deepcopy(valid_train_params_dict)
|
||||
del d['bucket_name']
|
||||
|
||||
@@ -18,8 +18,8 @@ def sample_params() -> TrainModelParams:
|
||||
'file_name': 'f.csv',
|
||||
'line_separator': '\n',
|
||||
'decimal_separator': '.',
|
||||
'date_column': None,
|
||||
'date_format': None,
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
|
||||
@@ -44,8 +44,8 @@ def _params(**kwargs) -> TrainModelParams:
|
||||
'file_name': 'f.csv',
|
||||
'line_separator': ',',
|
||||
'decimal_separator': '.',
|
||||
'date_column': None,
|
||||
'date_format': None,
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
@@ -63,23 +63,31 @@ def _params(**kwargs) -> TrainModelParams:
|
||||
return TrainModelParams.from_dict(base)
|
||||
|
||||
|
||||
def test_ensure_date_column_parsed_no_column():
|
||||
def test_ensure_date_column_parsed_missing_column_raises():
|
||||
df = pd.DataFrame({'a': [1]})
|
||||
p = _params(date_column='missing')
|
||||
out = dmr._ensure_date_column_parsed(df, p)
|
||||
assert out is df
|
||||
with pytest.raises(ValueError, match='not found in dataset columns'):
|
||||
dmr._ensure_date_column_parsed(df, p)
|
||||
|
||||
|
||||
def test_ensure_date_column_parsed_success():
|
||||
df = pd.DataFrame({'a': range(3), 'ts': ['2024-01-01 10:00:00+0000'] * 3})
|
||||
df = pd.DataFrame({'a': range(3), 'ts': ['2024-01-01 10:00:00'] * 3})
|
||||
p = _params(date_column='ts')
|
||||
out = dmr._ensure_date_column_parsed(df, p)
|
||||
assert pd.api.types.is_datetime64_any_dtype(out['ts'])
|
||||
|
||||
|
||||
def test_ensure_date_column_parsed_naive_with_frontend_format():
|
||||
"""CSV timestamps without timezone use params.date_format strftime mapping."""
|
||||
df = pd.DataFrame({'ts': ['2025-06-02 00:00:00', '2025-06-02 01:00:00']})
|
||||
p = _params(date_column='ts', date_format='yyyy-MM-dd HH:mm:ss')
|
||||
out = dmr._ensure_date_column_parsed(df, p)
|
||||
assert pd.api.types.is_datetime64_any_dtype(out['ts'])
|
||||
|
||||
|
||||
def test_ensure_date_column_parsed_invalid_raises():
|
||||
df = pd.DataFrame({'a': range(3), 'ts': ['not-a-date'] * 3})
|
||||
p = _params(date_column='ts', date_format='yyyy')
|
||||
p = _params(date_column='ts')
|
||||
with pytest.raises(ValueError, match='Failed to parse date column'):
|
||||
dmr._ensure_date_column_parsed(df, p)
|
||||
|
||||
@@ -98,9 +106,9 @@ def test_prepare_training_data_csv_load_failure():
|
||||
def test_prepare_training_data_empty_after_load():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
# empty csv with headers only
|
||||
csv_bytes = b'v1,t\n'
|
||||
with pytest.raises(ValueError, match='Index is not a DatetimeIndex'):
|
||||
# Headers only; timestamp column present but no data rows
|
||||
csv_bytes = b'timestamp,v1,t\n'
|
||||
with pytest.raises(ValueError, match='Training data view is empty after transformation'):
|
||||
repo.prepare_training_data(csv_bytes, None, p, {})
|
||||
|
||||
|
||||
@@ -114,7 +122,7 @@ def test_prepare_training_data_empty_after_transformation(monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr(repo, '_set_timezone_on_index', lambda data, *_args, **_kwargs: data)
|
||||
with pytest.raises(ValueError, match='Training data view is empty after transformation'):
|
||||
repo.prepare_training_data(b'v1,t\n', None, p, {})
|
||||
repo.prepare_training_data(b'timestamp,v1,t\n', None, p, {})
|
||||
|
||||
|
||||
def _minimal_dict_for_prepare():
|
||||
@@ -125,8 +133,8 @@ def _minimal_dict_for_prepare():
|
||||
'file_name': 'f.csv',
|
||||
'line_separator': ',',
|
||||
'decimal_separator': '.',
|
||||
'date_column': None,
|
||||
'date_format': None,
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'train_size': 80,
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
@@ -143,10 +151,10 @@ def _minimal_dict_for_prepare():
|
||||
|
||||
|
||||
def _csv_bytes_with_ts(n_rows: int = 20) -> bytes:
|
||||
"""CSV with leading timestamp column so _configure_datetime_index does not mangle feature columns."""
|
||||
"""CSV with leading timestamp column (naive, matches default date_format)."""
|
||||
lines = ['timestamp,v1,t']
|
||||
for i in range(n_rows):
|
||||
lines.append(f'2024-01-{i + 1:02d} 00:00:00+0000,{i},{i + 1}')
|
||||
lines.append(f'2024-01-{i + 1:02d} 00:00:00,{i},{i + 1}')
|
||||
return '\n'.join(lines).encode()
|
||||
|
||||
|
||||
@@ -175,6 +183,24 @@ def test_prepare_training_data_validation_empty_val():
|
||||
repo.prepare_training_data(train_csv, val_csv, p, {})
|
||||
|
||||
|
||||
def test_prepare_training_data_drops_row_with_blank_timestamp():
|
||||
"""Rows with empty date_column values are removed before datetime parsing."""
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
d = _minimal_dict_for_prepare()
|
||||
d['date_column'] = 'timestamp'
|
||||
d['date_format'] = 'yyyy-MM-dd HH:mm:ss'
|
||||
p = TrainModelParams.from_dict(d)
|
||||
lines = ['timestamp,v1,t']
|
||||
for i in range(10):
|
||||
if i == 3:
|
||||
lines.append(',1.0,2.0')
|
||||
else:
|
||||
lines.append(f'2025-06-01 {i:02d}:00:00,1.0,2.0')
|
||||
csv = '\n'.join(lines).encode()
|
||||
res = repo.prepare_training_data(csv, None, p, {})
|
||||
assert len(res.train_data) + len(res.val_data) == 9
|
||||
|
||||
|
||||
def test_prepare_training_data_split_path():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
@@ -192,6 +218,51 @@ def test_prepare_training_data_explicit_validation_success():
|
||||
assert len(res.val_data) == 5
|
||||
|
||||
|
||||
def test_coerce_non_timestamp_columns_to_numeric_success():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
idx = pd.date_range('2024-01-01', periods=2, freq='h', tz='UTC')
|
||||
df = pd.DataFrame(
|
||||
{'v1': ['1.25', '2.75'], 't': ['10', '11']},
|
||||
index=idx,
|
||||
)
|
||||
out = repo._coerce_non_timestamp_columns_to_numeric(df, p, {})
|
||||
assert pd.api.types.is_numeric_dtype(out['v1'])
|
||||
assert pd.api.types.is_numeric_dtype(out['t'])
|
||||
assert float(out['v1'].iloc[0]) == 1.25
|
||||
assert float(out['t'].iloc[1]) == 11.0
|
||||
|
||||
|
||||
def test_coerce_non_timestamp_columns_to_numeric_invalid_values_to_nan():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
idx = pd.date_range('2024-01-01', periods=2, freq='h', tz='UTC')
|
||||
df = pd.DataFrame(
|
||||
{'v1': ['1.25', 'oops'], 't': ['10', 'bad']},
|
||||
index=idx,
|
||||
)
|
||||
out = repo._coerce_non_timestamp_columns_to_numeric(df, p, {})
|
||||
assert np.isnan(out['v1'].iloc[1])
|
||||
assert np.isnan(out['t'].iloc[1])
|
||||
|
||||
|
||||
def test_prepare_training_data_coerces_non_timestamp_columns_to_numeric():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
lines = ['timestamp,v1,t']
|
||||
for i in range(10):
|
||||
v1 = 'bad' if i == 4 else f'{i + 0.5}'
|
||||
t = 'bad' if i == 7 else f'{i + 1.0}'
|
||||
lines.append(f'2025-06-01 {i:02d}:00:00,{v1},{t}')
|
||||
csv = '\n'.join(lines).encode()
|
||||
res = repo.prepare_training_data(csv, None, p, {})
|
||||
joined = pd.concat([res.train_data, res.val_data], axis=0).sort_index()
|
||||
assert pd.api.types.is_numeric_dtype(joined['v1'])
|
||||
assert pd.api.types.is_numeric_dtype(joined['t'])
|
||||
assert joined['v1'].isna().sum() == 1
|
||||
assert joined['t'].isna().sum() == 1
|
||||
|
||||
|
||||
def test_as_series_series():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
s = pd.Series([1.0, 2.0])
|
||||
@@ -259,6 +330,24 @@ def test_compute_regression_metrics_linear_equation():
|
||||
assert out.mse_val is not None and out.equation is not None
|
||||
|
||||
|
||||
def test_compute_regression_metrics_linear_skips_equation_without_sklearn_regr():
|
||||
"""E2E dummy wrappers expose model without sklearn .regr; metrics still compute."""
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
p.model_type = 'linear_regression'
|
||||
idx = pd.Index([0, 1])
|
||||
tmr = TrainModelResult(
|
||||
params=p,
|
||||
train_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
|
||||
val_data=pd.DataFrame({'t': [1.0, 2.0]}, index=idx),
|
||||
y_pred=pd.DataFrame({'p': [1.0, 2.0]}, index=idx),
|
||||
)
|
||||
wrapper = MagicMock()
|
||||
wrapper.model = object()
|
||||
out = repo.compute_regression_metrics(tmr, wrapper)
|
||||
assert out.mse_val is not None and out.equation is None
|
||||
|
||||
|
||||
def test_compute_regression_metrics_non_linear_skips_equation():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
@@ -284,24 +373,20 @@ def test_configure_datetime_index_none_raises():
|
||||
def test_configure_datetime_index_already_datetime_index():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
idx = pd.date_range('2024-01-01', periods=3, freq='h')
|
||||
df = pd.DataFrame({'v1': [1, 2, 3], 't': [1, 2, 3]}, index=idx)
|
||||
out = repo._configure_datetime_index(df, p, {})
|
||||
assert isinstance(out.index, pd.DatetimeIndex)
|
||||
|
||||
|
||||
def test_configure_datetime_index_from_common_column():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
existing_idx = pd.date_range('2024-01-01', periods=3, freq='h')
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
'timestamp': pd.date_range('2024-01-01', periods=3, freq='D'),
|
||||
'timestamp': pd.to_datetime(
|
||||
['2024-01-03 00:00:00', '2024-01-01 00:00:00', '2024-01-02 00:00:00']
|
||||
),
|
||||
'v1': [1, 2, 3],
|
||||
't': [1, 2, 3],
|
||||
}
|
||||
},
|
||||
index=existing_idx,
|
||||
)
|
||||
out = repo._configure_datetime_index(df, p, {})
|
||||
assert isinstance(out.index, pd.DatetimeIndex)
|
||||
assert out.index.equals(pd.DatetimeIndex(pd.to_datetime(sorted(df['timestamp'].tolist()))))
|
||||
|
||||
|
||||
def test_configure_datetime_index_from_date_column():
|
||||
@@ -318,36 +403,32 @@ def test_configure_datetime_index_from_date_column():
|
||||
assert isinstance(out.index, pd.DatetimeIndex)
|
||||
|
||||
|
||||
def test_configure_datetime_index_bad_column_skips_to_first():
|
||||
def test_configure_datetime_index_missing_date_column_raises():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
df = pd.DataFrame({'timestamp': ['x'], 'v1': [1.0], 't': [1.0]})
|
||||
out = repo._configure_datetime_index(df, p, {})
|
||||
assert isinstance(out, pd.DataFrame)
|
||||
assert not isinstance(out.index, pd.DatetimeIndex)
|
||||
|
||||
|
||||
def test_configure_datetime_index_no_timestamp_warning():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
df = pd.DataFrame({'v1': [1, 2, 3], 't': [1, 2, 3]})
|
||||
out = repo._configure_datetime_index(df, p, {})
|
||||
assert isinstance(out, pd.DataFrame)
|
||||
|
||||
|
||||
def test_configure_datetime_index_first_column_numeric_parsed_as_time():
|
||||
"""Covers fallback path that parses the first column as datetime when it looks like timestamps."""
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict(_minimal_dict_for_prepare())
|
||||
p = TrainModelParams.from_dict({**_minimal_dict_for_prepare(), 'date_column': 'mydate'})
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
'ts': pd.date_range('2024-01-01', periods=3, freq='D'),
|
||||
'timestamp': pd.date_range('2024-01-01', periods=3, freq='D'),
|
||||
'v1': [1, 2, 3],
|
||||
't': [1, 2, 3],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match='date_column "mydate" not found'):
|
||||
repo._configure_datetime_index(df, p, {})
|
||||
|
||||
|
||||
def test_configure_datetime_index_non_datetime_date_column_raises():
|
||||
repo = dmr.DataManagerRepository(MagicMock())
|
||||
p = TrainModelParams.from_dict({**_minimal_dict_for_prepare(), 'date_column': 'mydate'})
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
'mydate': ['2024-01-01', '2024-01-02', '2024-01-03'],
|
||||
'v1': [1.0, 2.0, 3.0],
|
||||
't': [1.0, 2.0, 3.0],
|
||||
}
|
||||
)
|
||||
out = repo._configure_datetime_index(df, p, {})
|
||||
assert isinstance(out.index, pd.DatetimeIndex)
|
||||
with pytest.raises(ValueError, match='must be datetime before index configuration'):
|
||||
repo._configure_datetime_index(df, p, {})
|
||||
|
||||
|
||||
def test_create_run_directory_permission_error():
|
||||
|
||||
@@ -32,8 +32,8 @@ def sample_input_data():
|
||||
'file_name': 'test-file.csv',
|
||||
'line_separator': ',',
|
||||
'decimal_separator': '.',
|
||||
'date_column': None,
|
||||
'date_format': None,
|
||||
'date_column': 'timestamp',
|
||||
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||
'shuffle': True,
|
||||
'random_state': 42,
|
||||
'model_name': 'Linear Regression',
|
||||
|
||||
Reference in New Issue
Block a user