Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / data / online / source.py: 12%

247 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-25 05:45 +0800

1# Copyright 2026 Huawei Technologies Co., Ltd 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# ============================================================================ 

15"""Shared Online source loading and multi-source construction policy.""" 

16 

17from __future__ import annotations 

18 

19import os 

20from collections.abc import Callable, Mapping, Sequence 

21from typing import Any, TypeAlias 

22 

23from hyper_parallel.data.constants import ONLINE_FILE_FORMATS, ONLINE_SPLIT_NAMES, ONLINE_STOPPING_STRATEGIES 

24 

25OnlinePathSpec: TypeAlias = str | Sequence[str] 

26OnlineDataPath: TypeAlias = OnlinePathSpec | Mapping[str, OnlinePathSpec] 

27 

28 

29class _OnlineSourceBuilder: 

30 """Share source parsing and interleave policy across Online adapters.""" 

31 

32 def __init__(self, data_path: OnlineDataPath | None, data_config: Mapping[str, Any]) -> None: 

33 """Store the source and composition configuration.""" 

34 self.data_path = data_path 

35 self.data_config = data_config 

36 

37 @property 

38 def _random_seed(self) -> int: 

39 """Return the common source shuffle and blend seed.""" 

40 return int(self.data_config.get("random_seed", 42)) 

41 

42 @property 

43 def _stopping_strategy(self) -> str: 

44 """Return the multi-source exhaustion policy.""" 

45 stopping_strategy = str(self.data_config.get("stopping_strategy", "first_exhausted")) 

46 if stopping_strategy not in ONLINE_STOPPING_STRATEGIES: 

47 raise ValueError( 

48 f"Unsupported Online stopping_strategy {stopping_strategy!r}; " 

49 f"expected one of {ONLINE_STOPPING_STRATEGIES!r}" 

50 ) 

51 return stopping_strategy 

52 

53 def _create_source_loaders(self) -> tuple[list[_OnlineLeafSourceLoader], list[float]]: 

54 """Create one leaf source loader per configured source. 

55 

56 Example: 

57 ``data_path="/data/train.jsonl"`` produces one source with weight 

58 ``1.0``. 

59 

60 The following configuration produces two sources. Both inherit 

61 ``split="98, 1, 1"``, while the second source overrides it:: 

62 

63 { 

64 "split": "98, 1, 1", 

65 "sources": [ 

66 {"data_path": "/data/a.parquet", "weight": 0.7}, 

67 { 

68 "data_path": "/data/b.parquet", 

69 "weight": 0.3, 

70 "split": "90, 5, 5", 

71 }, 

72 ], 

73 } 

74 

75 Returns: 

76 Leaf source loaders and their corresponding unnormalized weights. 

77 """ 

78 sources = self.data_config.get("sources") 

79 if sources is None: 

80 if self.data_path is None: 

81 raise ValueError("Online Dataset requires data_path or data_config.sources") 

82 source_loader = _OnlineLeafSourceLoader(self.data_path, self.data_config) 

83 return [source_loader], [1.0] 

84 

85 if self.data_path is not None: 

86 raise ValueError("Online Dataset cannot combine data_path with data_config.sources") 

87 if isinstance(sources, (str, bytes)) or not isinstance(sources, Sequence) or not sources: 

88 raise ValueError("Online data_config.sources must be a non-empty sequence") 

89 

90 common_config = dict(self.data_config) 

91 common_config.pop("sources") 

92 source_loaders = [] 

93 source_weights = [] 

94 for source in sources: 

95 if not isinstance(source, Mapping): 

96 raise ValueError("Each Online source must be a mapping with data_path and weight") 

97 try: 

98 source_path = source["data_path"] 

99 source_weight = float(source["weight"]) 

100 except KeyError as error: 

101 raise ValueError("Each Online source must define data_path and weight") from error 

102 if source_weight <= 0: 

103 raise ValueError("Online source weight must be positive") 

104 

105 source_config = dict(common_config) 

106 for option_name, option_value in source.items(): 

107 if option_name not in ("data_path", "weight"): 

108 source_config[option_name] = option_value 

109 source_loaders.append(_OnlineLeafSourceLoader(source_path, source_config)) 

110 source_weights.append(source_weight) 

111 return source_loaders, source_weights 

112 

113 def _interleave_sources(self, source_datasets: Sequence[Any], weights: Sequence[float]) -> Any: 

114 """Interleave Mapping or Iterable sources through the Hugging Face interface.""" 

115 if len(source_datasets) == 1: 

116 return source_datasets[0] 

117 

118 try: 

119 # datasets remains optional for Indexed-only installations. 

120 from datasets import interleave_datasets # pylint: disable=C0415 

121 except ImportError as error: 

122 raise ImportError("Online Dataset requires the optional 'datasets' package") from error 

123 

124 weight_sum = sum(weights) 

125 probabilities = [] 

126 for weight in weights: 

127 probability = weight / weight_sum 

128 probabilities.append(probability) 

129 

130 interleaved_dataset = interleave_datasets( 

131 list(source_datasets), 

132 probabilities=probabilities, 

133 seed=self._random_seed, 

134 stopping_strategy=self._stopping_strategy, 

135 ) 

136 return interleaved_dataset 

137 

138 

139class _OnlineLeafSourceLoader: 

140 """Load one local or Hub source with ``datasets.load_dataset``.""" 

141 

142 def __init__(self, data_path: OnlineDataPath, data_config: Mapping[str, Any]) -> None: 

143 """Store one leaf source description.""" 

144 self.data_path = data_path 

145 self.data_config = data_config 

146 

147 def load( 

148 self, 

149 *, 

150 streaming: bool, 

151 sample_filter: Callable[[Mapping[str, Any]], bool] | None = None, 

152 ) -> Any: 

153 """Load, restore configured splits, and filter one leaf source.""" 

154 try: 

155 # datasets remains optional for Indexed-only installations. 

156 from datasets import load_dataset # pylint: disable=C0415 

157 except ImportError as error: 

158 raise ImportError("Online Dataset requires the optional 'datasets' package") from error 

159 

160 # Examples: "Salesforce/wikitext", "/data/*.parquet", {"train": "/data/train.jsonl"}. 

161 load_dataset_path, load_options, split_expressions = self._build_load_request(streaming=streaming) 

162 ratio_split_config = self.data_config.get("split") 

163 filter_before_ratio_split = ratio_split_config is not None and sample_filter is not None 

164 selected_split: str | list[str] = "train" 

165 if split_expressions is not None and not filter_before_ratio_split: 

166 selected_splits = [] 

167 for split_expression in split_expressions: 

168 if split_expression is not None: 

169 selected_splits.append(split_expression) 

170 selected_split = selected_splits 

171 

172 loaded_dataset = load_dataset( 

173 load_dataset_path, 

174 **load_options, 

175 split=selected_split, 

176 streaming=streaming, 

177 cache_dir=self.data_config.get("cache_dir"), 

178 ) 

179 

180 if filter_before_ratio_split: 

181 filtered_dataset = loaded_dataset.filter(sample_filter) 

182 split_percentages = self._parse_split_percentages(ratio_split_config) 

183 filtered_splits = self._split_filtered_dataset(filtered_dataset, split_percentages) 

184 return filtered_splits 

185 

186 dataset_result = loaded_dataset 

187 if split_expressions is not None: 

188 # Example: ("train", None, "test") keeps the empty valid slot. 

189 loaded_dataset_iterator = iter(loaded_dataset) 

190 restored_splits = [] 

191 for split_expression in split_expressions: 

192 if split_expression is None: 

193 restored_splits.append(None) 

194 continue 

195 loaded_split = next(loaded_dataset_iterator) 

196 restored_splits.append(loaded_split) 

197 dataset_result = (restored_splits[0], restored_splits[1], restored_splits[2]) 

198 

199 if sample_filter is None: 

200 return dataset_result 

201 

202 # Example: (train, None, test) filters available splits and preserves the empty valid slot. 

203 if isinstance(dataset_result, tuple): 

204 filtered_splits = [] 

205 for split_dataset in dataset_result: 

206 if split_dataset is None: 

207 filtered_splits.append(None) 

208 continue 

209 filtered_split = split_dataset.filter(sample_filter) 

210 filtered_splits.append(filtered_split) 

211 filtered_dataset = (filtered_splits[0], filtered_splits[1], filtered_splits[2]) 

212 return filtered_dataset 

213 

214 filtered_dataset = dataset_result.filter(sample_filter) 

215 return filtered_dataset 

216 

217 def _build_load_request( 

218 self, 

219 *, 

220 streaming: bool, 

221 ) -> tuple[str, dict[str, Any], tuple[str | None, str | None, str | None] | None]: 

222 """Build the path, keyword arguments, and split selections for ``load_dataset``.""" 

223 split_config = self.data_config.get("split") 

224 split_expressions = None 

225 if split_config is not None and streaming: 

226 raise ValueError("Online ratio split does not support streaming") 

227 

228 if split_config is not None and isinstance(self.data_path, Mapping): 

229 raise ValueError("Online ratio split cannot be combined with pre-split paths") 

230 

231 if split_config is not None: 

232 split_expressions = self._parse_split_ratios(split_config) 

233 

234 load_options: dict[str, Any] = {} 

235 # Example: {"train": "/data/train.jsonl", "valid": "/data/valid.jsonl"}. 

236 if isinstance(self.data_path, Mapping): 

237 data_files, load_dataset_path, split_expressions = self._resolve_split_data_files(self.data_path) 

238 load_options["data_files"] = data_files 

239 resolved_request = (load_dataset_path, load_options, split_expressions) 

240 return resolved_request 

241 

242 is_local_path = not isinstance(self.data_path, str) 

243 if isinstance(self.data_path, str): 

244 file_extension = os.path.splitext(self.data_path)[1].lower() 

245 is_local_path = os.path.exists(self.data_path) or file_extension in ONLINE_FILE_FORMATS 

246 

247 # Examples: ["/data/a.jsonl", "/data/b.jsonl"], "/data/*.parquet". 

248 if is_local_path: 

249 data_files, load_dataset_path = self._collect_local_data_files(self.data_path) 

250 load_options["data_files"] = data_files 

251 resolved_request = (load_dataset_path, load_options, split_expressions) 

252 return resolved_request 

253 

254 # Example: "Salesforce/wikitext" is a Hub dataset ID. 

255 load_dataset_path = self.data_path 

256 config_name = self.data_config.get("config_name") 

257 if config_name is not None: 

258 load_options["name"] = config_name 

259 

260 resolved_request = (load_dataset_path, load_options, split_expressions) 

261 return resolved_request 

262 

263 @staticmethod 

264 def _split_filtered_dataset(dataset: Any, percentages: Sequence[int]) -> tuple[Any, Any, Any]: 

265 """Split one filtered Mapping Dataset using deterministic contiguous ranges.""" 

266 dataset_length = len(dataset) 

267 train_end = int(round(percentages[0] * dataset_length / 100.0)) 

268 valid_end = int(round((percentages[0] + percentages[1]) * dataset_length / 100.0)) 

269 split_ranges = ((0, train_end), (train_end, valid_end), (valid_end, dataset_length)) 

270 

271 split_datasets = [] 

272 for percentage, (split_start, split_end) in zip(percentages, split_ranges): 

273 split_dataset = None 

274 if percentage > 0: 

275 split_dataset = dataset.select(range(split_start, split_end)) 

276 split_datasets.append(split_dataset) 

277 dataset_splits = (split_datasets[0], split_datasets[1], split_datasets[2]) 

278 return dataset_splits 

279 

280 def _resolve_split_data_files( 

281 self, 

282 data_path: Mapping[str, OnlinePathSpec], 

283 ) -> tuple[dict[str, list[str]], str, tuple[str | None, str | None, str | None]]: 

284 """Resolve pre-split local paths into ``load_dataset`` arguments.""" 

285 if not data_path: 

286 raise ValueError("Online split data_path mapping must not be empty") 

287 

288 split_data_files: dict[str, list[str]] = {} 

289 loader_formats = set() 

290 for split_name, split_path in data_path.items(): 

291 if split_name not in ONLINE_SPLIT_NAMES: 

292 raise ValueError( 

293 f"Unsupported Online data_path split {split_name!r}; " 

294 f"expected one of {ONLINE_SPLIT_NAMES!r}" 

295 ) 

296 

297 data_files, loader_format = self._collect_local_data_files(split_path) 

298 split_data_files[split_name] = data_files 

299 loader_formats.add(loader_format) 

300 

301 if len(loader_formats) != 1: 

302 raise ValueError("Online train/valid/test paths must use one common file format") 

303 

304 train_split = None 

305 if "train" in split_data_files: 

306 train_split = "train" 

307 

308 valid_split = None 

309 if "valid" in split_data_files: 

310 valid_split = "valid" 

311 

312 test_split = None 

313 if "test" in split_data_files: 

314 test_split = "test" 

315 

316 selected_splits = (train_split, valid_split, test_split) 

317 loader_format = loader_formats.pop() 

318 return split_data_files, loader_format, selected_splits 

319 

320 @staticmethod 

321 def _collect_local_data_files(data_path: OnlinePathSpec) -> tuple[list[str], str]: 

322 """Collect local files and select their ``load_dataset`` format.""" 

323 if isinstance(data_path, str): 

324 configured_paths = [data_path] 

325 else: 

326 configured_paths = list(data_path) 

327 

328 data_files = [] 

329 for configured_path in configured_paths: 

330 if not os.path.isdir(configured_path): 

331 data_files.append(configured_path) 

332 continue 

333 

334 for filename in sorted(os.listdir(configured_path)): 

335 file_extension = os.path.splitext(filename)[1].lower() 

336 if file_extension in ONLINE_FILE_FORMATS: 

337 data_files.append(os.path.join(configured_path, filename)) 

338 

339 if not data_files: 

340 raise ValueError("Online files must use one format: JSON/JSONL/Parquet/CSV/Arrow") 

341 

342 first_extension = os.path.splitext(data_files[0])[1].lower() 

343 loader_format = ONLINE_FILE_FORMATS.get(first_extension) 

344 for data_file in data_files[1:]: 

345 file_extension = os.path.splitext(data_file)[1].lower() 

346 if ONLINE_FILE_FORMATS.get(file_extension) != loader_format: 

347 raise ValueError("Online files must use one format: JSON/JSONL/Parquet/CSV/Arrow") 

348 

349 if loader_format is None: 

350 raise ValueError("Online files must use one format: JSON/JSONL/Parquet/CSV/Arrow") 

351 

352 return data_files, loader_format 

353 

354 @staticmethod 

355 def _parse_split_percentages(split: Any) -> tuple[int, int, int]: 

356 """Parse and validate train, validation, and test percentages.""" 

357 try: 

358 percentages = [] 

359 for split_part in split.split(","): 

360 percentages.append(int(split_part.strip())) 

361 except (AttributeError, ValueError) as error: 

362 raise ValueError("Online split must contain three integers such as '98, 1, 1'") from error 

363 

364 if len(percentages) != 3: 

365 raise ValueError("Online split must contain three integers such as '98, 1, 1'") 

366 

367 if sum(percentages) != 100: 

368 raise ValueError("Online split percentages must sum to 100") 

369 if any(percentage < 0 for percentage in percentages): 

370 raise ValueError("Online split percentages must be non-negative") 

371 

372 split_percentages = (percentages[0], percentages[1], percentages[2]) 

373 return split_percentages 

374 

375 @classmethod 

376 def _parse_split_ratios(cls, split: Any) -> tuple[str | None, str | None, str | None]: 

377 """Translate train/valid/test percentages into ``load_dataset`` split expressions.""" 

378 train_percentage, valid_percentage, test_percentage = cls._parse_split_percentages(split) 

379 

380 train_end = train_percentage 

381 valid_end = train_percentage + valid_percentage 

382 split_ranges = ((0, train_end), (train_end, valid_end), (valid_end, 100)) 

383 

384 split_expressions: list[str | None] = [] 

385 for split_begin, split_end in split_ranges: 

386 if split_begin == split_end: 

387 split_expression = None 

388 elif split_begin == 0 and split_end == 100: 

389 split_expression = "train" 

390 elif split_begin == 0: 

391 split_expression = f"train[:{split_end}%]" 

392 elif split_end == 100: 

393 split_expression = f"train[{split_begin}%:]" 

394 else: 

395 split_expression = f"train[{split_begin}%:{split_end}%]" 

396 split_expressions.append(split_expression) 

397 

398 online_split_expressions = (split_expressions[0], split_expressions[1], split_expressions[2]) 

399 return online_split_expressions 

400 

401 

402__all__ = [ 

403 "OnlineDataPath", 

404 "OnlinePathSpec", 

405]