Coverage for collectra/tasks/machine_learning/training.py: 82.96%
135 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1"""Opt-in utilities for training task implementations."""
3from __future__ import annotations
5import shutil
6from datetime import datetime
7from pathlib import Path
8from typing import Callable
10import yaml
11from rich.console import Console
12from rich.table import Table
14from collectra.types.base import ArtefactNode
15from collectra.types.images import Image, ImageCrop
16from collectra.utils import change_dir
18from ...logger import get_logger
20logger = get_logger(__name__)
23def print_distribution_table(
24 title: str,
25 classes: list[str],
26 train_counts: dict[str, int],
27 validation_counts: dict[str, int],
28) -> None:
29 """Print class counts and each class's held-out validation percentage."""
30 table = Table(title=title, show_lines=True)
31 table.add_column(
32 "Class Name", justify="left", style="green", header_style="bold green"
33 )
34 table.add_column(
35 "Train Count", justify="right", style="red", header_style="bold red"
36 )
37 table.add_column(
38 "Validation Count", justify="right", style="blue", header_style="bold blue"
39 )
40 table.add_column("Validation %", justify="right", header_style="bold")
42 total_train = 0
43 total_validation = 0
44 for class_name in classes:
45 train_count = train_counts[class_name]
46 validation_count = validation_counts[class_name]
47 total = train_count + validation_count
48 percentage = 100 * validation_count / total if total else 0.0
49 table.add_row(
50 class_name,
51 str(train_count),
52 str(validation_count),
53 f"{percentage:.1f}%",
54 )
55 total_train += train_count
56 total_validation += validation_count
58 grand_total = total_train + total_validation
59 total_percentage = 100 * total_validation / grand_total if grand_total else 0.0
60 table.add_row(
61 "Total",
62 str(total_train),
63 str(total_validation),
64 f"{total_percentage:.1f}%",
65 style="bold",
66 )
67 Console().print(table)
70def prepare_object_detection_inputs(
71 processed_inputs: list,
72 processed_parents: list,
73 input_maps: dict[str, list],
74 parent_input_maps: dict[str, list],
75) -> list:
76 """Attach source images to detection crops and return the task inputs."""
77 for item in processed_inputs:
78 if type(item) is Image:
79 continue
80 if type(item) is not ImageCrop:
81 raise TypeError(f"Input {item.id} is not an ImageCrop")
82 if isinstance(item.parents, str):
83 parent_id = item.parents
84 elif isinstance(item.parents, list) and len(item.parents) == 1:
85 parent_id = item.parents[0]
86 else:
87 continue
88 parent = next(
89 (
90 candidate
91 for candidate in processed_parents
92 if candidate.id == parent_id and item.data == candidate.data
93 ),
94 None,
95 )
96 if not isinstance(parent, Image):
97 raise ValueError(
98 f"Image parent {parent_id!r} for input {item.id!r} was not found"
99 )
100 item.add_source_parent(parent)
101 return processed_inputs
104def _training_files(task, inputs: list[str]) -> list[Path]:
105 pipeline = _pipeline_for(task)
106 files = []
107 for value in inputs:
108 path = Path(value)
109 if path.is_dir():
110 files.extend(path.glob(f"*.{pipeline.ext}"))
111 elif path.suffix == f".{pipeline.ext}":
112 files.append(path)
113 return files
116def _file_partition(item_file: Path) -> str:
117 result_file = item_file / "results.yaml"
118 if not result_file.exists():
119 return ""
120 with open(result_file, "r") as f:
121 file_data = yaml.safe_load(f) or {}
122 metadata = file_data.get("collectra_results_metadata") or {}
123 return metadata.get("partition") or ""
126def _limit_training_files(
127 files: list[Path], max_items: int, validation: str = "", exclude: str = ""
128) -> list[Path]:
129 """Keep the first ``max_items`` training and ``max_items`` validation files.
131 Only the partition metadata is read, and reading stops once both
132 quotas are filled, so large datasets are not loaded for quick tests.
133 """
134 selected = []
135 counts = {"train": 0, "validation": 0}
136 for item_file in sorted(files):
137 partition = _file_partition(item_file)
138 if exclude and partition == exclude:
139 continue
140 split = "validation" if validation and partition == validation else "train"
141 if counts[split] < max_items:
142 selected.append(item_file)
143 counts[split] += 1
144 if counts["train"] >= max_items and (
145 not validation or counts["validation"] >= max_items
146 ):
147 break
148 logger.info(
149 "max_items=%d: using %d training and %d validation files",
150 max_items,
151 counts["train"],
152 counts["validation"],
153 )
154 return selected
157def _prepare_data(
158 task,
159 files: list[Path],
160 nodes: list[ArtefactNode],
161 *,
162 include_unlabelled: bool = False,
163):
164 processed = []
165 input_maps = {}
166 for item_file in files:
167 input_maps[item_file.name] = ArtefactNode.batch_process(
168 item_file,
169 nodes,
170 include_unlabelled=include_unlabelled,
171 )
172 processed.extend(input_maps[item_file.name])
173 return processed, input_maps
176def _pipeline_for(task):
177 if task.pipeline is None:
178 raise RuntimeError(f"Task {task.name!r} is not attached to a pipeline")
179 return task.pipeline
182def save_training_result(task, results, *, log: str, **kwargs) -> None:
183 """Copy the best model and optional class metadata into the pipeline."""
184 pipeline = _pipeline_for(task)
185 best_model_path = results.save_dir / "weights" / "best.pt"
186 if not best_model_path.exists():
187 raise FileNotFoundError(f"Best model file not found at {best_model_path}")
188 logger.info("Training metrics: %s", results.results_dict)
189 current_model = pipeline.data.get(task.name, {}).get("model", "")
190 if best_model_path.name == current_model:
191 return
192 new_model_path = f"{task.name}-{Path(log).name}.pt"
193 shutil.copy(best_model_path, new_model_path)
194 pipeline.data[task.name]["model"] = new_model_path
195 classes_src = best_model_path.parent / "classes.json"
196 if classes_src.exists():
197 shutil.copy(classes_src, f"{Path(new_model_path).stem}.classes.json")
200def train_from_files(
201 task,
202 inputs: list[str],
203 trainer: Callable,
204 *,
205 prepare_inputs: Callable | None = None,
206 include_unlabelled: bool = False,
207 **kwargs,
208):
209 """Resolve pipeline artefacts and invoke a concrete backend trainer."""
210 pipeline = _pipeline_for(task)
211 task_node = pipeline.node_manager.resolve_node(task.name)
212 children = pipeline.node_manager.get_children_artefact(task_node)
213 parents = pipeline.node_manager.get_ancestor_artefacts(task_node)
214 kwargs["classes"] = kwargs.get("classes", [child.name for child in children])
215 kwargs = pipeline._merge_task_params(task.name, kwargs)
216 files = _training_files(task, inputs)
217 max_items = int(kwargs.get("max_items", 0) or 0)
218 if max_items > 0:
219 files = _limit_training_files(
220 files,
221 max_items,
222 validation=kwargs.get("validation", ""),
223 exclude=kwargs.get("exclude", ""),
224 )
225 processed_inputs, input_maps = _prepare_data(
226 task,
227 files,
228 children,
229 include_unlabelled=include_unlabelled,
230 )
231 processed_parents, parent_input_maps = _prepare_data(task, files, parents)
232 if prepare_inputs is not None:
233 processed_inputs = prepare_inputs(
234 processed_inputs,
235 processed_parents,
236 input_maps,
237 parent_input_maps,
238 )
239 with change_dir(pipeline.path):
240 results = trainer(*processed_inputs, **kwargs)
241 save_training_result(task, results, **kwargs)
242 return results
245def run_training_command(
246 task,
247 inputs: list[str],
248 trainer: Callable,
249 *,
250 keep_log: bool,
251 output: Path | None,
252 validation: str,
253 exclude: str,
254 prepare_inputs: Callable | None = None,
255 include_unlabelled: bool = False,
256 **kwargs,
257):
258 """Run the common filesystem lifecycle requested by a concrete command."""
259 pipeline = _pipeline_for(task)
260 log = (
261 Path(output).expanduser().resolve()
262 if output is not None
263 else Path.cwd() / datetime.now().strftime("%Y%m%d_%H%M%S")
264 )
265 results = train_from_files(
266 task,
267 inputs,
268 trainer,
269 prepare_inputs=prepare_inputs,
270 include_unlabelled=include_unlabelled,
271 project=f"{pipeline.path.name}-{task.name}",
272 log=log,
273 base_folder=Path.cwd(),
274 validation=validation,
275 exclude=exclude,
276 **kwargs,
277 )
278 pipeline.save(task.name)
279 if not keep_log:
280 shutil.rmtree(log, ignore_errors=True)
281 return results