@@ -611,14 +611,23 @@ def _create_dataset(self, load_minutes, now_utc, pv_minutes=None, temp_minutes=N
611611 # Validation window: most recent chunks (chunk_idx 0 … validation_end_chunk-1)
612612 validation_end_chunk = validation_holdout_hours * 60 // CHUNK_MINUTES
613613
614- X_train_list = []
614+ # Preallocate the feature matrices and write each sample straight into its row. Holding
615+ # a Python list of per-sample arrays and then copying it into np.array() keeps both
616+ # alive at once, which for a three week window is an extra 70MB on top of the 66MB
617+ # matrix. Rows are sized for the maximum possible sample count and the filled prefix is
618+ # returned; samples are only skipped where the history has gaps, so the slack is small.
619+ max_train_rows = max (max_chunk_idx - LOOKBACK_STEPS , 0 )
620+ max_val_rows = max (validation_end_chunk , 0 )
621+ X_train_all = np .empty ((max_train_rows , TOTAL_FEATURES ), dtype = np .float32 )
622+ X_val_all = np .empty ((max_val_rows , TOTAL_FEATURES ), dtype = np .float32 )
623+ train_rows = 0
624+ val_rows = 0
615625 y_train_list = []
616626 weight_list = []
617- X_val_list = []
618627 y_val_list = []
619628
620- def _build_sample (target_chunk_idx ):
621- """Build one ( features, target) sample centred on target_chunk_idx ."""
629+ def _build_sample (target_chunk_idx , out_row ):
630+ """Write one sample's features into out_row and return its target, or None on a gap ."""
622631 lookback_start = target_chunk_idx + 1
623632 lookback_values = []
624633 pv_lookback_values = []
@@ -635,12 +644,12 @@ def _build_sample(target_chunk_idx):
635644 import_rate_lookback .append (chunked_import .get (lb_idx , 0.0 ))
636645 export_rate_lookback .append (chunked_export .get (lb_idx , 0.0 ))
637646 else :
638- return None , None # Gap in data - skip
647+ return None # Gap in data - skip
639648
640649 if len (lookback_values ) != LOOKBACK_STEPS :
641- return None , None
650+ return None
642651 if target_chunk_idx not in chunked_energy :
643- return None , None
652+ return None
644653
645654 target_value = chunked_energy [target_chunk_idx ]
646655
@@ -651,25 +660,22 @@ def _build_sample(target_chunk_idx):
651660 day_of_year = target_time .timetuple ().tm_yday
652661 time_features = self ._create_time_features (minute_of_day , day_of_week , day_of_year )
653662
654- features = np .concatenate (
655- [
656- np .array (lookback_values , dtype = np .float32 ),
657- np .array (pv_lookback_values , dtype = np .float32 ),
658- np .array (temp_lookback_values , dtype = np .float32 ),
659- np .array (import_rate_lookback , dtype = np .float32 ),
660- np .array (export_rate_lookback , dtype = np .float32 ),
661- time_features ,
662- ]
663- )
664- return features , np .array ([target_value ], dtype = np .float32 )
663+ # Assigning the lists straight into row slices converts them in place, avoiding the
664+ # five temporary arrays and the concatenated result this used to build per sample
665+ offset = 0
666+ for values in (lookback_values , pv_lookback_values , temp_lookback_values , import_rate_lookback , export_rate_lookback ):
667+ out_row [offset : offset + LOOKBACK_STEPS ] = values
668+ offset += LOOKBACK_STEPS
669+ out_row [offset :] = time_features
670+ return np .array ([target_value ], dtype = np .float32 )
665671
666672 # Training samples: all available chunks
667673 for target_chunk_idx in range (0 , max_chunk_idx - LOOKBACK_STEPS ):
668- features , target = _build_sample (target_chunk_idx )
669- if features is None :
674+ target = _build_sample (target_chunk_idx , X_train_all [ train_rows ] )
675+ if target is None :
670676 continue
671677
672- X_train_list . append ( features )
678+ train_rows += 1
673679 y_train_list .append (target )
674680
675681 # Time-decay weighting (older samples get lower weight)
@@ -678,23 +684,23 @@ def _build_sample(target_chunk_idx):
678684
679685 # Validation samples: most recent validation_end_chunk chunks
680686 for target_chunk_idx in range (0 , validation_end_chunk ):
681- features , target = _build_sample (target_chunk_idx )
682- if features is None :
687+ target = _build_sample (target_chunk_idx , X_val_all [ val_rows ] )
688+ if target is None :
683689 continue
684- X_val_list . append ( features )
690+ val_rows += 1
685691 y_val_list .append (target )
686692
687- if not X_train_list :
693+ if not train_rows :
688694 return None , None , None , None , None
689695
690- X_train = np . array ( X_train_list , dtype = np . float32 )
696+ X_train = X_train_all [: train_rows ]
691697 y_train = np .array (y_train_list , dtype = np .float32 )
692698 train_weights = np .array (weight_list , dtype = np .float32 )
693699
694700 # Normalize weights to sum to number of samples
695701 train_weights = train_weights * len (train_weights ) / np .sum (train_weights )
696702
697- X_val = np . array ( X_val_list , dtype = np . float32 ) if X_val_list else None
703+ X_val = X_val_all [: val_rows ] if val_rows else None
698704 y_val = np .array (y_val_list , dtype = np .float32 ) if y_val_list else None
699705
700706 return X_train , y_train , train_weights , X_val , y_val
@@ -808,6 +814,43 @@ def _ar_rollout_diagnostic(self, load_minutes, now_utc, pv_minutes=None, temp_mi
808814
809815 return float (np .mean (errors )), float (np .mean (biases ))
810816
817+ @staticmethod
818+ def _feature_mean_std (X , block = 512 ):
819+ """
820+ Compute per-feature mean and std over row blocks, accumulating in float64.
821+
822+ np.mean/np.std on a float32 array accumulate in float32, which loses low bits on the
823+ feature groups that sit far from zero, and np.std materialises the deviations array
824+ internally - another full copy of a 66MB training matrix to produce 1,446 numbers.
825+ Two passes over row blocks avoids both: no temporary larger than one block, and
826+ float64 accumulators that match a float64 reference.
827+
828+ Args:
829+ X: Feature array, shape (samples, features)
830+ block: Rows accumulated per pass. The float64 deviation buffer is
831+ block * features * 8 bytes, so this bounds the working set: 512 rows of
832+ 1,446 features is 5.9MB against 47.4MB at 4096.
833+
834+ Returns:
835+ Tuple of (mean, std) as float32 arrays
836+ """
837+ rows = len (X )
838+ total = np .zeros (X .shape [1 ], dtype = np .float64 )
839+ for start in range (0 , rows , block ):
840+ total += X [start : start + block ].sum (axis = 0 , dtype = np .float64 )
841+ mean = total / rows
842+
843+ squares = np .zeros (X .shape [1 ], dtype = np .float64 )
844+ for start in range (0 , rows , block ):
845+ deviation = X [start : start + block ].astype (np .float64 )
846+ deviation -= mean
847+ # Square into the deviation buffer rather than allocating a second one of the
848+ # same size; the values are identical either way
849+ squares += np .square (deviation , out = deviation ).sum (axis = 0 )
850+ std = np .sqrt (squares / rows )
851+
852+ return mean .astype (np .float32 ), std .astype (np .float32 )
853+
811854 def _get_min_std_array (self , n_features ):
812855 """
813856 Return the per-feature minimum std array used to prevent extreme normalization.
@@ -818,7 +861,10 @@ def _get_min_std_array(self, n_features):
818861 Returns:
819862 numpy array of minimum std values, shape (n_features,)
820863 """
821- min_std = np .ones (n_features ) * 1e-8 # Default fallback
864+ # float32 to match the dataset and weights: a float64 minimum promotes the whole
865+ # normalised feature matrix to float64 via np.maximum, doubling its size and running
866+ # training in double precision against float32 weights
867+ min_std = np .ones (n_features , dtype = np .float32 ) * 1e-8 # Default fallback
822868 if n_features == TOTAL_FEATURES :
823869 min_std [0 :LOOKBACK_STEPS ] = 0.01 # Load energy (kWh)
824870 min_std [LOOKBACK_STEPS : 2 * LOOKBACK_STEPS ] = 0.01 # PV energy (kWh)
@@ -858,7 +904,7 @@ def _log_normalization_stats(self, label=""):
858904
859905 self .log ("ML Predictor: Normalization stats [{}] target(mean={:.4f} std={:.4f}) {}" .format (label , self .target_mean if self .target_mean is not None else 0 , self .target_std if self .target_std is not None else 0 , " " .join (parts )))
860906
861- def _normalize_features (self , X , fit = False , ema_alpha = 0.0 ):
907+ def _normalize_features (self , X , fit = False , ema_alpha = 0.0 , in_place = False ):
862908 """
863909 Normalize features using z-score normalization with feature-specific minimum stds.
864910
@@ -868,22 +914,25 @@ def _normalize_features(self, X, fit=False, ema_alpha=0.0):
868914 ema_alpha: If > 0 and existing params exist, blend new stats with old via EMA
869915 (new = alpha * new_stats + (1-alpha) * old_stats). Used during
870916 fine-tuning to track feature distribution drift without sudden jumps.
917+ in_place: If True, write the normalised values back into X instead of building a
918+ new array. A training feature matrix is around 66MB, and the caller
919+ keeps no use for the un-normalised values, so copying doubles the
920+ resident cost of a training pass for nothing. Only pass this when the
921+ caller is finished with the array it hands in.
871922
872923 Returns:
873924 Normalized feature array
874925 """
875926 if fit :
876- self .feature_mean = np .mean (X , axis = 0 )
877- self .feature_std = np .std (X , axis = 0 )
927+ self .feature_mean , self .feature_std = self ._feature_mean_std (X )
878928
879929 # Clamp std to per-feature minimums to prevent extreme normalization
880930 self .feature_std = np .maximum (self .feature_std , self ._get_min_std_array (len (self .feature_std )))
881931 self ._log_normalization_stats (label = "fit" )
882932
883933 elif ema_alpha > 0 and self .feature_mean is not None and self .feature_std is not None :
884934 # EMA update: blend new statistics with existing to track distribution drift
885- new_mean = np .mean (X , axis = 0 )
886- new_std = np .std (X , axis = 0 )
935+ new_mean , new_std = self ._feature_mean_std (X )
887936
888937 # Apply same min-std clamping to new stats before blending
889938 new_std = np .maximum (new_std , self ._get_min_std_array (len (new_std )))
@@ -896,6 +945,11 @@ def _normalize_features(self, X, fit=False, ema_alpha=0.0):
896945 if self .feature_mean is None or self .feature_std is None :
897946 return X
898947
948+ if in_place :
949+ X -= self .feature_mean
950+ X /= self .feature_std
951+ return X
952+
899953 return (X - self .feature_mean ) / self .feature_std
900954
901955 def _normalize_targets (self , y , fit = False ):
@@ -1039,13 +1093,13 @@ def train(
10391093 # On initial train: fit normalization from scratch
10401094 # On fine-tune: apply EMA update to track distribution drift gradually
10411095 if is_initial or not self .model_initialized :
1042- X_train_norm = self ._normalize_features (X_train , fit = True )
1096+ X_train_norm = self ._normalize_features (X_train , fit = True , in_place = True )
10431097 y_train_norm = self ._normalize_targets (y_train , fit = True )
10441098 else :
1045- X_train_norm = self ._normalize_features (X_train , fit = False , ema_alpha = norm_ema_alpha )
1099+ X_train_norm = self ._normalize_features (X_train , fit = False , ema_alpha = norm_ema_alpha , in_place = True )
10461100 y_train_norm = self ._normalize_targets (y_train , fit = False )
10471101 self .log ("ML Predictor: Applied EMA normalization update (alpha={}) to track feature drift" .format (norm_ema_alpha ))
1048- X_val_norm = self ._normalize_features (X_val , fit = False )
1102+ X_val_norm = self ._normalize_features (X_val , fit = False , in_place = True )
10491103 y_val_norm = self ._normalize_targets (y_val , fit = False )
10501104
10511105 # Initialise weights if needed
0 commit comments