Logo ROOT  
Reference Guide
 
Loading...
Searching...
No Matches
RClusterLoader.hxx
Go to the documentation of this file.
1// Author: Dante Niewenhuis, VU Amsterdam 07/2023
2// Author: Kristupas Pranckietis, Vilnius University 05/2024
3// Author: Nopphakorn Subsa-Ard, King Mongkut's University of Technology Thonburi (KMUTT) (TH) 08/2024
4// Author: Vincenzo Eduardo Padulano, CERN 10/2024
5// Author: Silia Taider, CERN 03/2026
6
7/*************************************************************************
8 * Copyright (C) 1995-2025, Rene Brun and Fons Rademakers. *
9 * All rights reserved. *
10 * *
11 * For the licensing terms see $ROOTSYS/LICENSE. *
12 * For the list of contributors see $ROOTSYS/README/CREDITS. *
13 *************************************************************************/
14
15#ifndef ROOT_INTERNAL_ML_RCLUSTERLOADER
16#define ROOT_INTERNAL_ML_RCLUSTERLOADER
17
18#include <algorithm>
19#include <numeric>
20#include <random>
21#include <string>
22#include <utility>
23#include <vector>
24
27#include "ROOT/RDataFrame.hxx"
28#include "ROOT/RDFHelpers.hxx"
29#include "ROOT/RDF/Utils.hxx"
30
32
33/**
34 * \struct RClusterRange
35 * \brief Describes a contiguous range of entries within a single RDataFrame,
36 * corresponding to one TTree/RNTuple cluster boundary.
37 *
38 * For filtered RDataFrames the \p numEntries field may be smaller than `end - start`
39 * because it tracks the number of entries that actually pass the filter,
40 * discovered and set lazily during the first epoch.
41 */
43 std::size_t rdfIdx; // which rdf this cluster belongs to
44 std::uint64_t start; // first raw entry (incl)
45 std::uint64_t end; // one-past-last entry (excl)
46 std::size_t numEntries{
47 static_cast<std::size_t>(end - start)}; // number of entries in the cluster (that pass filters, if any)
48
49 std::size_t GetNumEntries() const { return numEntries; }
50 void SetNumEntries(std::size_t num) { numEntries = num; }
51};
52
53/**
54 * \class ROOT::Experimental::Internal::ML::RClusterLoaderFunctor
55 * \brief Functor invoked by RDataFrame::Foreach to fill one row of an RFlat2DMatrix.
56 *
57 */
58
59template <typename... ColTypes>
61 std::size_t fOffset{};
62 std::size_t fVecSizeIdx{};
63 float fVecPadding{};
64 std::vector<std::size_t> fMaxVecSizes{};
66
67 std::size_t fNumChunkCols;
68
69 int fI;
71
72 //////////////////////////////////////////////////////////////////////////
73 /// \brief \brief Copy the content of a column into the current tensor when the column consists of vectors
75 void AssignToTensor(const T &vec, int i, int numColumns)
76 {
77 std::size_t max_vec_size = fMaxVecSizes[fVecSizeIdx++];
78 std::size_t vec_size = vec.size();
79
80 float *dst = fChunkTensor.GetData() + fOffset + numColumns * i;
81 if (vec_size < max_vec_size) // Padding vector column to max_vec_size with fVecPadding
82 {
83 std::copy(vec.begin(), vec.end(), dst);
84 std::fill(dst + vec_size, dst + max_vec_size, fVecPadding);
85 } else // Copy only max_vec_size length from vector column
86 {
87 std::copy(vec.begin(), vec.begin() + max_vec_size, dst);
88 }
90 }
91
92 //////////////////////////////////////////////////////////////////////////
93 /// \brief Copy the content of a column into the current tensor when the column consists of scalar values
95 void AssignToTensor(const T &val, int i, int numColumns)
96 {
98 fOffset++;
99 }
100
101public:
103 const std::vector<std::size_t> &maxVecSizes, float vecPadding, int i,
104 std::size_t rowOffset = 0)
108 fI(i),
111 {
112 }
113
114 void operator()(const ColTypes &...cols)
115 {
116 fVecSizeIdx = 0;
118 }
119};
120
121/**
122 * \class ROOT::Experimental::Internal::ML::RClusterLoader
123 * \brief Loads TTree/RNTuple clusters from one or more RDataFrames into RFlat2DMatrix
124 * buffers for ML training and validation.
125 *
126 * ### Overview
127 * At construction the loader scans the cluster boundaries of every
128 * provided RDataFrame and stores them as a flat list of \ref RClusterRange objects.
129 * SplitDataset() then partitions those ranges into training and validation sets according to \p validationSplit.
130 *
131 * ### The split strategy depends on whether shuffling is enabled or not
132 * - **Unshuffled**: one cut is made so that the first `(1 - validationSplit)`
133 * fraction of entries goes to training. At most one cluster is split at the boundary.
134 * - **Shuffled**: each cluster is split proportionally (according to `validationSplit`)
135 * so both sets draw entries from every part of the dataset. ShuffleTrainingClusters()
136 * and ShuffleValidationClusters() re-order the cluster lists at the start of each epoch.
137 * A second shuffling step, at the entries level, happens inside LoadTrainingClusterInto()
138 * and LoadValidationClusterInto() when loading the data into the tensors.
139 *
140 * ### Filtered RDataFrames
141 * When any RDataFrame carries a filter, the true entry count is not known
142 * until the computation graph is executed. In this case SplitDataset() is a
143 * no-op and the split is discovered lazily inside LoadTrainingClusterInto()
144 * during the first epoch.
145 * After the first epoch FinaliseSplitDiscovery() marks the split as stable and
146 * all subsequent epochs use the same pre-computed ranges.
147 */
148template <typename... Args>
150private:
151 std::vector<ROOT::RDF::RNode> &fRdfs;
152 std::vector<std::size_t> fRdfSizes;
153 std::vector<std::string> fCols;
154 std::vector<std::size_t> fVecSizes;
158 std::size_t fSetSeed;
159
160 std::size_t fNumCols;
161 std::size_t fSumVecSizes;
162 std::size_t fNumChunkCols;
163
164 std::vector<RClusterRange> fAllClusters;
165 std::vector<RClusterRange> fTrainingClusters;
166 std::vector<RClusterRange> fValidationClusters;
167
168 std::size_t fTotalEntries{0};
169 std::size_t fNumTrainingEntries{0};
170 std::size_t fNumValidationEntries{0};
171
172 bool fIsFiltered{false};
173 bool fSplitDiscovered{false};
176
177public:
178 RClusterLoader(std::vector<ROOT::RDF::RNode> &rdfs, const std::vector<std::string> &cols,
179 const std::vector<std::size_t> &vecSizes, float vecPadding, float validationSplit, bool shuffle,
180 std::size_t setSeed)
181 : fRdfs(rdfs),
182 fCols(cols),
188 {
189 fNumCols = fCols.size();
190 fSumVecSizes = std::accumulate(fVecSizes.begin(), fVecSizes.end(), 0UL);
192
193 for (auto &rdf : fRdfs) {
194 // TODO(staider) We need a better API in RDF to detect generically whether there's a filter or not
195 if (!rdf.GetFilterNames().empty()) {
196 fIsFiltered = true;
197 break;
198 }
199 }
200
201 fRdfSizes.resize(fRdfs.size(), 0);
202
203 // scan cluster boundaries across files
204 // TODO(staider) Add progress bar to inform the user about this potentially long operation
205 for (std::size_t rdfIdx = 0; rdfIdx < fRdfs.size(); ++rdfIdx) {
207 fAllClusters.push_back({rdfIdx, r.first, r.second});
208 auto numEntries = r.second - r.first;
209 fRdfSizes[rdfIdx] += numEntries;
210 fTotalEntries += numEntries;
211 }
212 }
213 }
214
215 //////////////////////////////////////////////////////////////////////////
216 /// \brief Distribute the clusters into training and validation datasets
217 /// No-op for filtered RDataFrames, the split is discovered lazily during the first epoch.
219 {
220 if (fAllClusters.empty())
221 throw std::runtime_error("RClusterLoader::SplitDataset: no clusters found.");
222
223 if (fIsFiltered) {
224 return;
225 }
226
227 if (fShuffle) {
228 // --- Shuffled path
229 // Every cluster contributes a prefix to training and a suffix to validation.
230 // Cost: Each cluster is read twice per epoch, only when validation split is more than 0.
231 // We generate a random boolean value to decide whether the training set gets the prefix
232 // or suffix of each cluster to ensure better shuffling across runs when splitting.
233 std::mt19937 g(fSetSeed);
234 std::uniform_int_distribution<int> coin(0, 1);
235
236 std::size_t cumulativeEntries = 0;
237 std::size_t currentCumulativeTrain = 0;
238 // We iterate over clusters and accumulate the entry counts to assign training and validation sizes
239 // proportionally to the cluster size. Filtered clusters have varying sizes, so instead of calculating
240 // the training size as a fraction of each cluster's size independently, we take into account
241 // the cumulative counts of previous clusters in each calculation.
242 for (const RClusterRange &c : fAllClusters) {
243 const std::size_t sz = c.GetNumEntries();
245 const std::size_t targetCumulativeTrain =
246 static_cast<std::size_t>(cumulativeEntries * (1.0f - fValidationSplit));
249 const std::size_t valSz = sz - trainSz;
250
251 // Randomly assign prefix or suffix to training
252 bool trainIsPrefix = coin(g);
253 const uint64_t trainStart = trainIsPrefix ? c.start : c.start + static_cast<std::uint64_t>(valSz);
254 const uint64_t valStart = trainIsPrefix ? c.start + static_cast<std::uint64_t>(trainSz) : c.start;
255
256 if (trainSz > 0) {
257 fTrainingClusters.push_back({c.rdfIdx, trainStart, trainStart + static_cast<std::uint64_t>(trainSz)});
259 }
260 if (valSz > 0) {
261 fValidationClusters.push_back({c.rdfIdx, valStart, valStart + static_cast<std::uint64_t>(valSz)});
263 }
264 }
265 } else {
266 // --- Unshuffled path
267 // Contiguous split: first (1 - validationSplit) fraction of entries go to
268 // training, the remainder to validation. At most one cluster is split at
269 // the boundary.
270 const std::size_t targetTraining = fTotalEntries - static_cast<std::size_t>(fValidationSplit * fTotalEntries);
271
272 std::size_t accumulated = 0;
273 std::size_t splitIdx = 0;
274 for (; splitIdx < fAllClusters.size(); ++splitIdx) {
275 const std::size_t sz = fAllClusters[splitIdx].GetNumEntries();
276 if (accumulated + sz > targetTraining) {
277 break;
278 }
279 accumulated += sz;
280 }
281
282 // Assign whole train/val clusters
283 fTrainingClusters.assign(fAllClusters.begin(), fAllClusters.begin() + splitIdx);
285
287 // Split the boundary cluster
289 const std::uint64_t splitPoint = boundary.start + static_cast<std::uint64_t>(targetTraining - accumulated);
290
291 fTrainingClusters.push_back({boundary.rdfIdx, boundary.start, splitPoint});
292 fValidationClusters.push_back({boundary.rdfIdx, splitPoint, boundary.end});
294 fAllClusters.end());
295
297 } else {
298 fValidationClusters.assign(fAllClusters.begin() + splitIdx, fAllClusters.end());
299 }
300
302 }
303
304 if (fTrainingClusters.empty())
305 throw std::runtime_error("RClusterLoader::SplitDataset: no entries for training after split. "
306 "Reduce validation_split.");
307
308 if (fValidationSplit > 0.0f && fValidationClusters.empty())
309 throw std::runtime_error("RClusterLoader::SplitDataset: no entries for validation after split. "
310 "Increase validation_split.");
311 }
312
313 //////////////////////////////////////////////////////////////////////////
314 /// \brief Re-order training clusters for the upcoming epoch
316 {
317 if (!fShuffle) {
318 return;
319 }
320
321 std::mt19937 g(fSetSeed == 0 ? std::random_device{}() : fSetSeed ^ epochIdx);
322 std::shuffle(fTrainingClusters.begin(), fTrainingClusters.end(), g);
323 }
324
325 //////////////////////////////////////////////////////////////////////////
326 /// \brief Re-order validation clusters for the upcoming epoch
328 {
329 if (!fShuffle) {
330 return;
331 }
332 std::mt19937 g(fSetSeed == 0 ? std::random_device{}() : fSetSeed ^ epochIdx);
333 std::shuffle(fValidationClusters.begin(), fValidationClusters.end(), g);
334 }
335
336 void LoadClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow, std::uint64_t endRow,
337 std::size_t rowOffset = 0)
338 {
339 ROOT::RDF::RNode &rdf = fRdfs[rdfIdx];
342 rdf.Foreach(func, fCols);
344 }
345
346 //////////////////////////////////////////////////////////////////////////
347 /// \brief Load one training cluster and return the number of rows written.
348 ///
349 /// **Unfiltered**: delegates directly to `LoadClusterInto()`
350 /// **Filtered**, epoch 1 (!fSplitDiscovered):
351 /// - On the first call, Count() is called across all RDFs to obtain
352 /// the total filtered entry count, fNumTrainingEntries and
353 /// fNumValidationEntries are set as targets.
354 /// - A single Foreach on the full raw cluster range loads data and captures
355 /// rdfentry_ simultaneously. The real train/val boundary is computed from
356 /// the accumulated filtered count vs the target, then the train sub-range
357 /// is pushed to fTrainingClusters and the val sub-range to fValidationClusters.
358 /// - Only the train rows are written into \p dest.
359 /// -All subsequent epochs: delegates directly to `LoadClusterInto()`
360 std::size_t LoadTrainingClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow,
361 std::uint64_t endRow, std::size_t rowOffset = 0)
362 {
364 // First call: discover total filtered count and set split targets.
366 std::vector<ROOT::RDF::RResultPtr<ULong64_t>> counts;
367 counts.reserve(fRdfs.size());
368 for (auto &rdf : fRdfs) {
369 counts.push_back(rdf.Count());
370 }
372
373 std::size_t totalFiltered = 0;
374 for (auto &c : counts) {
375 totalFiltered += c.GetValue();
376 }
377 fNumTrainingEntries = static_cast<std::size_t>(totalFiltered * (1.0f - fValidationSplit));
379 }
380
381 ROOT::RDF::RNode &rdf = fRdfs[rdfIdx];
382
383 std::vector<ULong64_t> rdfEntries;
384 rdfEntries.reserve(endRow - startRow);
385
387 rdf.Foreach([&](ULong64_t entry) { rdfEntries.push_back(entry); }, {"rdfentry_"});
389
390 const std::size_t totalFiltered = rdfEntries.size();
391 if (totalFiltered == 0) {
392 return 0;
393 }
394 std::sort(rdfEntries.begin(), rdfEntries.end());
395
396 const std::size_t cumulativeFiltered =
398 const std::size_t targetCumulativeTrain =
399 std::min(static_cast<std::size_t>(cumulativeFiltered * (1.0f - fValidationSplit)), fNumTrainingEntries);
401 const std::size_t valCount = totalFiltered - trainCount;
402
403 bool trainIsPrefix = true;
404 if (fShuffle) {
405 // If shuffling is enabled, we generate a random boolean value to decide whether the training set
406 // gets the prefix or suffix of each cluster to ensure better shuffling across runs when splitting.
407 std::mt19937 g(fSetSeed + fAccumulatedFilteredForTrain); // vary per cluster
408 std::uniform_int_distribution<int> coin(0, 1);
410 }
411
412 // The boundary is the raw entry index that splits train and val sub-ranges within the
413 // cluster. Stable across epochs since the same filter always produces the same ordered
414 // entries. When one side has no filtered entries we fall back to the cluster endpoint that
415 // collapses that side to an empty range, avoiding an out-of-bounds access into rdfEntries
416 // (whose size is totalFiltered, so rdfEntries[totalFiltered] is OOB and trips libstdc++
417 // hardened-mode assertions).
418 std::uint64_t boundary;
419 if (trainIsPrefix) {
420 // train = [startRow, boundary), val = [boundary, endRow)
422 } else {
423 // train = [boundary, endRow), val = [startRow, boundary)
425 }
426
427 const std::uint64_t trainStart = trainIsPrefix ? startRow : boundary;
428 const std::uint64_t trainEnd = trainIsPrefix ? boundary : endRow;
429 const std::uint64_t valStart = trainIsPrefix ? boundary : startRow;
430 const std::uint64_t valEnd = trainIsPrefix ? endRow : boundary;
431
432 if (trainCount > 0)
433 fTrainingClusters.push_back({rdfIdx, trainStart, trainEnd, trainCount});
434 if (valCount > 0)
435 fValidationClusters.push_back({rdfIdx, valStart, valEnd, valCount});
436
439
440 if (trainCount > 0)
442
443 return trainCount;
444 }
445
447 return endRow - startRow;
448 }
449
450 //////////////////////////////////////////////////////////////////////////
451 /// \brief Load one validation cluster into \p dest starting at \p rowOffset
452 void LoadValidationClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow, std::uint64_t endRow,
453 std::size_t rowOffset = 0)
454 {
456 }
457
458 //////////////////////////////////////////////////////////////////////////
459 /// \brief Mark the train/val split as finalised after the first epoch
461 {
462 if (fIsFiltered)
463 fSplitDiscovered = true;
464 }
465
466 bool IsSplitDiscovered() const { return !fIsFiltered || fSplitDiscovered; }
467
468 //////////////////////////////////////////////////////////////////////////
469 // Accessors
470 std::size_t GetNumTrainingEntries() const { return fNumTrainingEntries; }
471 std::size_t GetNumValidationEntries() const { return fNumValidationEntries; }
472 std::size_t GetNumChunkCols() const { return fNumChunkCols; }
473
474 const std::vector<RClusterRange> &GetTrainingClusters() const
475 {
477 }
478 const std::vector<RClusterRange> &GetValidationClusters() const { return fValidationClusters; }
479
480 std::size_t GetNumTrainingClusters() const
481 {
482 return (fIsFiltered && !fSplitDiscovered) ? fAllClusters.size() : fTrainingClusters.size();
483 }
484 std::size_t GetNumValidationClusters() const { return fValidationClusters.size(); }
485 std::size_t GetNmTotalClusters() const { return fAllClusters.size(); }
486};
487
488} // namespace ROOT::Experimental::Internal::ML
489#endif // ROOT_INTERNAL_ML_RCLUSTERLOADER
#define c(i)
Definition RSha256.hxx:101
#define g(i)
Definition RSha256.hxx:105
ROOT::Detail::TRangeCast< T, true > TRangeDynCast
TRangeDynCast is an adapter class that allows the typed iteration through a TCollection.
Option_t Option_t TPoint TPoint const char GetTextMagnitude GetFillStyle GetLineColor GetLineWidth GetMarkerStyle GetTextAlign GetTextColor GetTextSize void char Point_t Rectangle_t dest
Option_t Option_t TPoint TPoint const char GetTextMagnitude GetFillStyle GetLineColor GetLineWidth GetMarkerStyle GetTextAlign GetTextColor GetTextSize void char Point_t Rectangle_t WindowAttributes_t Float_t r
Functor invoked by RDataFrame::Foreach to fill one row of an RFlat2DMatrix.
void AssignToTensor(const T &vec, int i, int numColumns)
Copy the content of a column into the current tensor when the column consists of vectors.
RClusterLoaderFunctor(RFlat2DMatrix &chunkTensor, std::size_t numColumns, const std::vector< std::size_t > &maxVecSizes, float vecPadding, int i, std::size_t rowOffset=0)
void AssignToTensor(const T &val, int i, int numColumns)
Copy the content of a column into the current tensor when the column consists of scalar values.
Loads TTree/RNTuple clusters from one or more RDataFrames into RFlat2DMatrix buffers for ML training ...
void ShuffleTrainingClusters(std::size_t epochIdx)
Re-order training clusters for the upcoming epoch.
void FinaliseSplitDiscovery()
Mark the train/val split as finalised after the first epoch.
void LoadClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow, std::uint64_t endRow, std::size_t rowOffset=0)
void ShuffleValidationClusters(std::size_t epochIdx)
Re-order validation clusters for the upcoming epoch.
std::size_t LoadTrainingClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow, std::uint64_t endRow, std::size_t rowOffset=0)
Load one training cluster and return the number of rows written.
void SplitDataset()
Distribute the clusters into training and validation datasets No-op for filtered RDataFrames,...
void LoadValidationClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow, std::uint64_t endRow, std::size_t rowOffset=0)
Load one validation cluster into dest starting at rowOffset.
const std::vector< RClusterRange > & GetTrainingClusters() const
RClusterLoader(std::vector< ROOT::RDF::RNode > &rdfs, const std::vector< std::string > &cols, const std::vector< std::size_t > &vecSizes, float vecPadding, float validationSplit, bool shuffle, std::size_t setSeed)
const std::vector< RClusterRange > & GetValidationClusters() const
The public interface to the RDataFrame federation of classes.
const_iterator begin() const
const_iterator end() const
std::vector< std::pair< std::uint64_t, std::uint64_t > > GetDatasetGlobalClusterBoundaries(const RNode &node)
Retrieve the cluster boundaries for each cluster in the dataset, across files, with a global offset.
void ChangeBeginAndEndEntries(const RNode &node, Long64_t begin, Long64_t end)
unsigned int RunGraphs(std::vector< RResultHandle > handles)
Run the event loops of multiple RDataFrames concurrently.
Describes a contiguous range of entries within a single RDataFrame, corresponding to one TTree/RNTupl...
Wrapper around ROOT::RVec<float> representing a 2D matrix.