Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-16 09:21:05

0001 // Author: Dante Niewenhuis, VU Amsterdam 07/2023
0002 // Author: Kristupas Pranckietis, Vilnius University 05/2024
0003 // Author: Nopphakorn Subsa-Ard, King Mongkut's University of Technology Thonburi (KMUTT) (TH) 08/2024
0004 // Author: Vincenzo Eduardo Padulano, CERN 10/2024
0005 // Author: Silia Taider, CERN 03/2026
0006 
0007 /*************************************************************************
0008  * Copyright (C) 1995-2025, Rene Brun and Fons Rademakers.               *
0009  * All rights reserved.                                                  *
0010  *                                                                       *
0011  * For the licensing terms see $ROOTSYS/LICENSE.                         *
0012  * For the list of contributors see $ROOTSYS/README/CREDITS.             *
0013  *************************************************************************/
0014 
0015 #ifndef ROOT_INTERNAL_ML_RCLUSTERLOADER
0016 #define ROOT_INTERNAL_ML_RCLUSTERLOADER
0017 
0018 #include <algorithm>
0019 #include <numeric>
0020 #include <random>
0021 #include <string>
0022 #include <utility>
0023 #include <vector>
0024 
0025 #include "ROOT/ML/RFlat2DMatrix.hxx"
0026 #include "ROOT/ML/RFlat2DMatrixOperators.hxx"
0027 #include "ROOT/RDataFrame.hxx"
0028 #include "ROOT/RDFHelpers.hxx"
0029 #include "ROOT/RDF/Utils.hxx"
0030 
0031 namespace ROOT::Experimental::Internal::ML {
0032 
0033 /**
0034  * \struct RClusterRange
0035  * \brief Describes a contiguous range of entries within a single RDataFrame,
0036  * corresponding to one TTree/RNTuple cluster boundary.
0037  *
0038  * For filtered RDataFrames the \p numEntries field may be smaller than `end - start`
0039  * because it tracks the number of entries that actually pass the filter,
0040  * discovered and set lazily during the first epoch.
0041  */
0042 struct RClusterRange {
0043    std::size_t rdfIdx;                  // which rdf this cluster belongs to
0044    std::uint64_t start;                 // first raw entry (incl)
0045    std::uint64_t end;                   // one-past-last entry (excl)
0046    std::size_t numEntries{
0047       static_cast<std::size_t>(end - start)}; // number of entries in the cluster (that pass filters, if any)
0048 
0049    std::size_t GetNumEntries() const { return numEntries; }
0050    void SetNumEntries(std::size_t num) { numEntries = num; }
0051 };
0052 
0053 /**
0054  * \class ROOT::Experimental::Internal::ML::RClusterLoaderFunctor
0055  * \brief Functor invoked by RDataFrame::Foreach to fill one row of an RFlat2DMatrix.
0056  *
0057  */
0058 
0059 template <typename... ColTypes>
0060 class RClusterLoaderFunctor {
0061    std::size_t fOffset{};
0062    std::size_t fVecSizeIdx{};
0063    float fVecPadding{};
0064    std::vector<std::size_t> fMaxVecSizes{};
0065    RFlat2DMatrix &fChunkTensor;
0066 
0067    std::size_t fNumChunkCols;
0068 
0069    int fI;
0070    int fNumColumns;
0071 
0072    //////////////////////////////////////////////////////////////////////////
0073    /// \brief \brief Copy the content of a column into the current tensor when the column consists of vectors
0074    template <typename T, std::enable_if_t<ROOT::Internal::RDF::IsDataContainer<T>::value, int> = 0>
0075    void AssignToTensor(const T &vec, int i, int numColumns)
0076    {
0077       std::size_t max_vec_size = fMaxVecSizes[fVecSizeIdx++];
0078       std::size_t vec_size = vec.size();
0079 
0080       float *dst = fChunkTensor.GetData() + fOffset + numColumns * i;
0081       if (vec_size < max_vec_size) // Padding vector column to max_vec_size with fVecPadding
0082       {
0083          std::copy(vec.begin(), vec.end(), dst);
0084          std::fill(dst + vec_size, dst + max_vec_size, fVecPadding);
0085       } else // Copy only max_vec_size length from vector column
0086       {
0087          std::copy(vec.begin(), vec.begin() + max_vec_size, dst);
0088       }
0089       fOffset += max_vec_size;
0090    }
0091 
0092    //////////////////////////////////////////////////////////////////////////
0093    /// \brief Copy the content of a column into the current tensor when the column consists of scalar values
0094    template <typename T, std::enable_if_t<!ROOT::Internal::RDF::IsDataContainer<T>::value, int> = 0>
0095    void AssignToTensor(const T &val, int i, int numColumns)
0096    {
0097       fChunkTensor.GetData()[fOffset + numColumns * i] = val;
0098       fOffset++;
0099    }
0100 
0101 public:
0102    RClusterLoaderFunctor(RFlat2DMatrix &chunkTensor, std::size_t numColumns,
0103                          const std::vector<std::size_t> &maxVecSizes, float vecPadding, int i,
0104                          std::size_t rowOffset = 0)
0105       : fChunkTensor(chunkTensor),
0106         fMaxVecSizes(maxVecSizes),
0107         fVecPadding(vecPadding),
0108         fI(i),
0109         fNumColumns(numColumns),
0110         fOffset(rowOffset * numColumns)
0111    {
0112    }
0113 
0114    void operator()(const ColTypes &...cols)
0115    {
0116       fVecSizeIdx = 0;
0117       (AssignToTensor(cols, fI, fNumColumns), ...);
0118    }
0119 };
0120 
0121 /**
0122  * \class ROOT::Experimental::Internal::ML::RClusterLoader
0123  * \brief Loads TTree/RNTuple clusters from one or more RDataFrames into RFlat2DMatrix
0124  *        buffers for ML training and validation.
0125  *
0126  * ### Overview
0127  * At construction the loader scans the cluster boundaries of every
0128  * provided RDataFrame and stores them as a flat list of \ref RClusterRange objects.
0129  * SplitDataset() then partitions those ranges into training and validation sets according to \p validationSplit.
0130  *
0131  * ### The split strategy depends on whether shuffling is enabled or not
0132  * - **Unshuffled**: one cut is made so that the first `(1 - validationSplit)`
0133  * fraction of entries goes to training. At most one cluster is split at the boundary.
0134  * - **Shuffled**: each cluster is split proportionally (according to `validationSplit`)
0135  * so both sets draw entries from every part of the dataset. ShuffleTrainingClusters()
0136  * and ShuffleValidationClusters() re-order the cluster lists at the start of each epoch.
0137  * A second shuffling step, at the entries level, happens inside LoadTrainingClusterInto()
0138  * and LoadValidationClusterInto() when loading the data into the tensors.
0139  *
0140  * ### Filtered RDataFrames
0141  * When any RDataFrame carries a filter, the true entry count is not known
0142  * until the computation graph is executed. In this case SplitDataset() is a
0143  * no-op and the split is discovered lazily inside LoadTrainingClusterInto()
0144  * during the first epoch.
0145  * After the first epoch FinaliseSplitDiscovery() marks the split as stable and
0146  * all subsequent epochs use the same pre-computed ranges.
0147  */
0148 template <typename... Args>
0149 class RClusterLoader {
0150 private:
0151    std::vector<ROOT::RDF::RNode> &fRdfs;
0152    std::vector<std::size_t> fRdfSizes;
0153    std::vector<std::string> fCols;
0154    std::vector<std::size_t> fVecSizes;
0155    float fVecPadding;
0156    float fValidationSplit;
0157    bool fShuffle;
0158    std::size_t fSetSeed;
0159 
0160    std::size_t fNumCols;
0161    std::size_t fSumVecSizes;
0162    std::size_t fNumChunkCols;
0163 
0164    std::vector<RClusterRange> fAllClusters;
0165    std::vector<RClusterRange> fTrainingClusters;
0166    std::vector<RClusterRange> fValidationClusters;
0167 
0168    std::size_t fTotalEntries{0};
0169    std::size_t fNumTrainingEntries{0};
0170    std::size_t fNumValidationEntries{0};
0171 
0172    bool fIsFiltered{false};
0173    bool fSplitDiscovered{false};
0174    std::size_t fAccumulatedFilteredForTrain{0};
0175 
0176 public:
0177    RClusterLoader(std::vector<ROOT::RDF::RNode> &rdfs, const std::vector<std::string> &cols,
0178                   const std::vector<std::size_t> &vecSizes, float vecPadding, float validationSplit, bool shuffle,
0179                   std::size_t setSeed)
0180       : fRdfs(rdfs),
0181         fCols(cols),
0182         fVecSizes(vecSizes),
0183         fVecPadding(vecPadding),
0184         fValidationSplit(validationSplit),
0185         fShuffle(shuffle),
0186         fSetSeed(setSeed)
0187    {
0188       fNumCols = fCols.size();
0189       fSumVecSizes = std::accumulate(fVecSizes.begin(), fVecSizes.end(), 0UL);
0190       fNumChunkCols = fNumCols + fSumVecSizes - fVecSizes.size();
0191 
0192       for (auto &rdf : fRdfs) {
0193          // TODO(staider) We need a better API in RDF to detect generically whether there's a filter or not
0194          if (!rdf.GetFilterNames().empty()) {
0195             fIsFiltered = true;
0196             break;
0197          }
0198       }
0199 
0200       fRdfSizes.resize(fRdfs.size(), 0);
0201 
0202       // scan cluster boundaries across files
0203       // TODO(staider) Add progress bar to inform the user about this potentially long operation
0204       for (std::size_t rdfIdx = 0; rdfIdx < fRdfs.size(); ++rdfIdx) {
0205          for (const auto &r : ROOT::Internal::RDF::GetDatasetGlobalClusterBoundaries(fRdfs[rdfIdx])) {
0206             fAllClusters.push_back({rdfIdx, r.first, r.second});
0207             auto numEntries = r.second - r.first;
0208             fRdfSizes[rdfIdx] += numEntries;
0209             fTotalEntries += numEntries;
0210          }
0211       }
0212    }
0213 
0214    //////////////////////////////////////////////////////////////////////////
0215    /// \brief Distribute the clusters into training and validation datasets
0216    /// No-op for filtered RDataFrames, the split is discovered lazily during the first epoch.
0217    void SplitDataset()
0218    {
0219       if (fAllClusters.empty())
0220          throw std::runtime_error("RClusterLoader::SplitDataset: no clusters found.");
0221 
0222       if (fIsFiltered) {
0223          return;
0224       }
0225 
0226       if (fShuffle) {
0227          // --- Shuffled path
0228          // Every cluster contributes a prefix to training and a suffix to validation.
0229          // Cost: Each cluster is read twice per epoch, only when validation split is more than 0.
0230          // We generate a random boolean value to decide whether the training set gets the prefix
0231          // or suffix of each cluster to ensure better shuffling across runs when splitting.
0232          std::mt19937 g(fSetSeed);
0233          std::uniform_int_distribution<int> coin(0, 1);
0234 
0235          for (const RClusterRange &c : fAllClusters) {
0236             const std::size_t sz = c.GetNumEntries();
0237             const std::size_t trainSz = static_cast<std::size_t>((1.0f - fValidationSplit) * sz);
0238             const std::size_t valSz = sz - trainSz;
0239 
0240             // Randomly assign prefix or suffix to training
0241             bool trainIsPrefix = coin(g);
0242             const uint64_t trainStart = trainIsPrefix ? c.start : c.start + static_cast<std::uint64_t>(valSz);
0243             const uint64_t valStart = trainIsPrefix ? c.start + static_cast<std::uint64_t>(trainSz) : c.start;
0244 
0245             if (trainSz > 0) {
0246                fTrainingClusters.push_back({c.rdfIdx, trainStart, trainStart + static_cast<std::uint64_t>(trainSz)});
0247                fNumTrainingEntries += trainSz;
0248             }
0249             if (valSz > 0) {
0250                fValidationClusters.push_back({c.rdfIdx, valStart, valStart + static_cast<std::uint64_t>(valSz)});
0251                fNumValidationEntries += valSz;
0252             }
0253          }
0254       } else {
0255          // --- Unshuffled path
0256          // Contiguous split: first (1 - validationSplit) fraction of entries go to
0257          // training, the remainder to validation. At most one cluster is split at
0258          // the boundary.
0259          const std::size_t targetTraining = fTotalEntries - static_cast<std::size_t>(fValidationSplit * fTotalEntries);
0260 
0261          std::size_t accumulated = 0;
0262          std::size_t splitIdx = 0;
0263          for (; splitIdx < fAllClusters.size(); ++splitIdx) {
0264             const std::size_t sz = fAllClusters[splitIdx].GetNumEntries();
0265             if (accumulated + sz > targetTraining) {
0266                break;
0267             }
0268             accumulated += sz;
0269          }
0270 
0271          // Assign whole train/val clusters
0272          fTrainingClusters.assign(fAllClusters.begin(), fAllClusters.begin() + splitIdx);
0273          fNumTrainingEntries = accumulated;
0274 
0275          if (splitIdx < fAllClusters.size() && accumulated < targetTraining) {
0276             // Split the boundary cluster
0277             const RClusterRange &boundary = fAllClusters[splitIdx];
0278             const std::uint64_t splitPoint = boundary.start + static_cast<std::uint64_t>(targetTraining - accumulated);
0279 
0280             fTrainingClusters.push_back({boundary.rdfIdx, boundary.start, splitPoint});
0281             fValidationClusters.push_back({boundary.rdfIdx, splitPoint, boundary.end});
0282             fValidationClusters.insert(fValidationClusters.end(), fAllClusters.begin() + splitIdx + 1,
0283                                        fAllClusters.end());
0284 
0285             fNumTrainingEntries += splitPoint - boundary.start;
0286          } else {
0287             fValidationClusters.assign(fAllClusters.begin() + splitIdx, fAllClusters.end());
0288          }
0289 
0290          fNumValidationEntries = fTotalEntries - fNumTrainingEntries;
0291       }
0292 
0293       if (fTrainingClusters.empty())
0294          throw std::runtime_error("RClusterLoader::SplitDataset: no entries for training after split. "
0295                                   "Reduce validation_split.");
0296 
0297       if (fValidationSplit > 0.0f && fValidationClusters.empty())
0298          throw std::runtime_error("RClusterLoader::SplitDataset: no entries for validation after split. "
0299                                   "Increase validation_split.");
0300    }
0301 
0302    //////////////////////////////////////////////////////////////////////////
0303    /// \brief Re-order training clusters for the upcoming epoch
0304    void ShuffleTrainingClusters(std::size_t epochIdx)
0305    {
0306       if (!fShuffle) {
0307          return;
0308       }
0309 
0310       std::mt19937 g(fSetSeed == 0 ? std::random_device{}() : fSetSeed ^ epochIdx);
0311       std::shuffle(fTrainingClusters.begin(), fTrainingClusters.end(), g);
0312    }
0313 
0314    //////////////////////////////////////////////////////////////////////////
0315    /// \brief Re-order validation clusters for the upcoming epoch
0316    void ShuffleValidationClusters(std::size_t epochIdx)
0317    {
0318       if (!fShuffle) {
0319          return;
0320       }
0321       std::mt19937 g(fSetSeed == 0 ? std::random_device{}() : fSetSeed ^ epochIdx);
0322       std::shuffle(fValidationClusters.begin(), fValidationClusters.end(), g);
0323    }
0324 
0325    void LoadClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow, std::uint64_t endRow,
0326                         std::size_t rowOffset = 0)
0327    {
0328       ROOT::RDF::RNode &rdf = fRdfs[rdfIdx];
0329       ROOT::Internal::RDF::ChangeBeginAndEndEntries(rdf, startRow, endRow);
0330       RClusterLoaderFunctor<Args...> func(dest, fNumChunkCols, fVecSizes, fVecPadding, 0, rowOffset);
0331       rdf.Foreach(func, fCols);
0332       ROOT::Internal::RDF::ChangeBeginAndEndEntries(rdf, 0, fRdfSizes[rdfIdx]);
0333    }
0334 
0335    //////////////////////////////////////////////////////////////////////////
0336    /// \brief Load one training cluster and return the number of rows written.
0337    ///
0338    /// **Unfiltered**: delegates directly to `LoadClusterInto()`
0339    /// **Filtered**, epoch 1 (!fSplitDiscovered):
0340    ///  - On the first call, Count() is called across all RDFs to obtain
0341    ///  the total filtered entry count, fNumTrainingEntries and
0342    ///  fNumValidationEntries are set as targets.
0343    ///  - A single Foreach on the full raw cluster range loads data and captures
0344    ///  rdfentry_ simultaneously. The real train/val boundary is computed from
0345    ///  the accumulated filtered count vs the target, then the train sub-range
0346    ///  is pushed to fTrainingClusters and the val sub-range to fValidationClusters.
0347    ///  - Only the train rows are written into \p dest.
0348    ///  -All subsequent epochs: delegates directly to `LoadClusterInto()`
0349    std::size_t LoadTrainingClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow,
0350                                        std::uint64_t endRow, std::size_t rowOffset = 0)
0351    {
0352       if (fIsFiltered && !fSplitDiscovered) {
0353          // First call: discover total filtered count and set split targets.
0354          if (fAccumulatedFilteredForTrain == 0 && fNumTrainingEntries == 0) {
0355             std::vector<ROOT::RDF::RResultPtr<ULong64_t>> counts;
0356             counts.reserve(fRdfs.size());
0357             for (auto &rdf : fRdfs) {
0358                counts.push_back(rdf.Count());
0359             }
0360             ROOT::RDF::RunGraphs({counts.begin(), counts.end()});
0361 
0362             std::size_t totalFiltered = 0;
0363             for (auto &c : counts) {
0364                totalFiltered += c.GetValue();
0365             }
0366             fNumTrainingEntries = static_cast<std::size_t>(totalFiltered * (1.0f - fValidationSplit));
0367             fNumValidationEntries = totalFiltered - fNumTrainingEntries;
0368          }
0369 
0370          ROOT::RDF::RNode &rdf = fRdfs[rdfIdx];
0371 
0372          // Fill data and collect raw entry indices that pass the filter
0373          std::vector<ULong64_t> rdfEntries;
0374          rdfEntries.reserve(endRow - startRow);
0375 
0376          RClusterLoaderFunctor<Args...> loader(dest, fNumChunkCols, fVecSizes, fVecPadding, 0, rowOffset);
0377          ROOT::Internal::RDF::ChangeBeginAndEndEntries(rdf, startRow, endRow);
0378 
0379          std::vector<std::string> colsWithEntry;
0380          colsWithEntry.reserve(fCols.size() + 1);
0381          colsWithEntry.push_back("rdfentry_");
0382          colsWithEntry.insert(colsWithEntry.end(), fCols.begin(), fCols.end());
0383 
0384          rdf.Foreach(
0385             [&](ULong64_t entry, const Args &...cols) {
0386                rdfEntries.push_back(entry);
0387                loader(cols...);
0388             },
0389             colsWithEntry);
0390 
0391          ROOT::Internal::RDF::ChangeBeginAndEndEntries(rdf, 0, fRdfSizes[rdfIdx]);
0392 
0393          const std::size_t totalFiltered = rdfEntries.size();
0394          if (totalFiltered == 0) {
0395             return 0;
0396          }
0397          std::sort(rdfEntries.begin(), rdfEntries.end());
0398 
0399          const std::size_t trainRemaining = fNumTrainingEntries - fAccumulatedFilteredForTrain;
0400          const std::size_t trainCount =
0401             std::min(static_cast<std::size_t>(totalFiltered * (1.0f - fValidationSplit)), trainRemaining);
0402          const std::size_t valCount = totalFiltered - trainCount;
0403 
0404          bool trainIsPrefix = true;
0405          if (fShuffle) {
0406             // If shuffling is enabled, we generate a random boolean value to decide whether the training set
0407             // gets the prefix or suffix of each cluster to ensure better shuffling across runs when splitting.
0408             std::mt19937 g(fSetSeed + fAccumulatedFilteredForTrain); // vary per cluster
0409             std::uniform_int_distribution<int> coin(0, 1);
0410             trainIsPrefix = coin(g);
0411          }
0412 
0413          // The boundary is the raw entry index that splits train and val sub-ranges within the
0414          // cluster. Stable across epochs since the same filter always produces the same ordered
0415          // entries. When one side has no filtered entries we fall back to the cluster endpoint that
0416          // collapses that side to an empty range, avoiding an out-of-bounds access into rdfEntries
0417          // (whose size is totalFiltered, so rdfEntries[totalFiltered] is OOB and trips libstdc++
0418          // hardened-mode assertions).
0419          std::uint64_t boundary;
0420          if (trainIsPrefix) {
0421             // train = [startRow, boundary), val = [boundary, endRow)
0422             boundary = (trainCount < totalFiltered) ? rdfEntries[trainCount] : endRow;
0423          } else {
0424             // train = [boundary, endRow), val = [startRow, boundary)
0425             boundary = (valCount < totalFiltered) ? rdfEntries[valCount] : endRow;
0426          }
0427 
0428          const std::uint64_t trainStart = trainIsPrefix ? startRow : boundary;
0429          const std::uint64_t trainEnd = trainIsPrefix ? boundary : endRow;
0430          const std::uint64_t valStart = trainIsPrefix ? boundary : startRow;
0431          const std::uint64_t valEnd = trainIsPrefix ? endRow : boundary;
0432 
0433          if (trainCount > 0)
0434             fTrainingClusters.push_back({rdfIdx, trainStart, trainEnd, trainCount});
0435          if (valCount > 0)
0436             fValidationClusters.push_back({rdfIdx, valStart, valEnd, valCount});
0437 
0438          fAccumulatedFilteredForTrain += trainCount;
0439          return trainCount;
0440       }
0441 
0442       LoadClusterInto(dest, rdfIdx, startRow, endRow, rowOffset);
0443       return endRow - startRow;
0444    }
0445 
0446    //////////////////////////////////////////////////////////////////////////
0447    /// \brief Load one validation cluster into \p dest starting at \p rowOffset
0448    void LoadValidationClusterInto(RFlat2DMatrix &dest, std::size_t rdfIdx, std::uint64_t startRow, std::uint64_t endRow,
0449                                   std::size_t rowOffset = 0)
0450    {
0451       LoadClusterInto(dest, rdfIdx, startRow, endRow, rowOffset);
0452    }
0453 
0454    //////////////////////////////////////////////////////////////////////////
0455    /// \brief Mark the train/val split as finalised after the first epoch
0456    void FinaliseSplitDiscovery()
0457    {
0458       if (fIsFiltered)
0459          fSplitDiscovered = true;
0460    }
0461 
0462    bool IsSplitDiscovered() const { return !fIsFiltered || fSplitDiscovered; }
0463 
0464    //////////////////////////////////////////////////////////////////////////
0465    // Accessors
0466    std::size_t GetNumTrainingEntries() const { return fNumTrainingEntries; }
0467    std::size_t GetNumValidationEntries() const { return fNumValidationEntries; }
0468    std::size_t GetNumChunkCols() const { return fNumChunkCols; }
0469 
0470    const std::vector<RClusterRange> &GetTrainingClusters() const
0471    {
0472       return (fIsFiltered && !fSplitDiscovered) ? fAllClusters : fTrainingClusters;
0473    }
0474    const std::vector<RClusterRange> &GetValidationClusters() const { return fValidationClusters; }
0475 
0476    std::size_t GetNumTrainingClusters() const
0477    {
0478       return (fIsFiltered && !fSplitDiscovered) ? fAllClusters.size() : fTrainingClusters.size();
0479    }
0480    std::size_t GetNumValidationClusters() const { return fValidationClusters.size(); }
0481    std::size_t GetNmTotalClusters() const { return fAllClusters.size(); }
0482 };
0483 
0484 } // namespace ROOT::Experimental::Internal::ML
0485 #endif // ROOT_INTERNAL_ML_RCLUSTERLOADER