halib 0.1.99__py3-none-any.whl → 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. halib/__init__.py +3 -3
  2. halib/common/__init__.py +0 -0
  3. halib/common/common.py +178 -0
  4. halib/common/rich_color.py +285 -0
  5. halib/filetype/csvfile.py +3 -9
  6. halib/filetype/ipynb.py +3 -5
  7. halib/filetype/jsonfile.py +0 -3
  8. halib/filetype/textfile.py +0 -1
  9. halib/filetype/videofile.py +91 -2
  10. halib/filetype/yamlfile.py +3 -3
  11. halib/online/projectmake.py +7 -6
  12. halib/online/tele_noti.py +165 -0
  13. halib/research/base_exp.py +75 -18
  14. halib/research/core/__init__.py +0 -0
  15. halib/research/core/base_config.py +144 -0
  16. halib/research/core/base_exp.py +157 -0
  17. halib/research/core/param_gen.py +108 -0
  18. halib/research/core/wandb_op.py +117 -0
  19. halib/research/data/__init__.py +0 -0
  20. halib/research/data/dataclass_util.py +41 -0
  21. halib/research/data/dataset.py +208 -0
  22. halib/research/data/torchloader.py +165 -0
  23. halib/research/dataset.py +1 -1
  24. halib/research/metrics.py +4 -0
  25. halib/research/mics.py +8 -2
  26. halib/research/perf/__init__.py +0 -0
  27. halib/research/perf/flop_calc.py +190 -0
  28. halib/research/perf/gpu_mon.py +58 -0
  29. halib/research/perf/perfcalc.py +363 -0
  30. halib/research/perf/perfmetrics.py +137 -0
  31. halib/research/perf/perftb.py +778 -0
  32. halib/research/perf/profiler.py +301 -0
  33. halib/research/perfcalc.py +57 -32
  34. halib/research/viz/__init__.py +0 -0
  35. halib/research/viz/plot.py +754 -0
  36. halib/system/filesys.py +60 -20
  37. halib/system/path.py +73 -0
  38. halib/utils/dict.py +9 -0
  39. halib/utils/list.py +12 -0
  40. {halib-0.1.99.dist-info → halib-0.2.2.dist-info}/METADATA +7 -1
  41. halib-0.2.2.dist-info/RECORD +89 -0
  42. halib-0.1.99.dist-info/RECORD +0 -64
  43. {halib-0.1.99.dist-info → halib-0.2.2.dist-info}/WHEEL +0 -0
  44. {halib-0.1.99.dist-info → halib-0.2.2.dist-info}/licenses/LICENSE.txt +0 -0
  45. {halib-0.1.99.dist-info → halib-0.2.2.dist-info}/top_level.txt +0 -0
halib/research/mics.py CHANGED
@@ -9,7 +9,7 @@ PC_NAME_TO_ABBR = {
9
9
  "DESKTOP-5IRHU87": "MSI_Laptop",
10
10
  "DESKTOP-96HQCNO": "4090_SV",
11
11
  "DESKTOP-Q2IKLC0": "4GPU_SV",
12
- "DESKTOP-QNS3DNF": "1GPU_SV"
12
+ "DESKTOP-QNS3DNF": "1GPU_SV",
13
13
  }
14
14
 
15
15
  DEFAULT_ABBR_WORKING_DISK = {
@@ -19,19 +19,25 @@ DEFAULT_ABBR_WORKING_DISK = {
19
19
  "4GPU_SV": "D:",
20
20
  }
21
21
 
22
+
22
23
  def list_PCs(show=True):
23
- df = pd.DataFrame(list(PC_NAME_TO_ABBR.items()), columns=["PC Name", "Abbreviation"])
24
+ df = pd.DataFrame(
25
+ list(PC_NAME_TO_ABBR.items()), columns=["PC Name", "Abbreviation"]
26
+ )
24
27
  if show:
25
28
  csvfile.fn_display_df(df)
26
29
  return df
27
30
 
31
+
28
32
  def get_PC_name():
29
33
  return platform.node()
30
34
 
35
+
31
36
  def get_PC_abbr_name():
32
37
  pc_name = get_PC_name()
33
38
  return PC_NAME_TO_ABBR.get(pc_name, "Unknown")
34
39
 
40
+
35
41
  # ! This funcction search for full paths in the obj and normalize them according to the current platform and working disk
36
42
  # ! E.g: "E:/zdataset/DFire", but working_disk: "D:", current_platform: "windows" => "D:/zdataset/DFire"
37
43
  # ! E.g: "E:/zdataset/DFire", but working_disk: "D:", current_platform: "linux" => "/mnt/d/zdataset/DFire"
File without changes
@@ -0,0 +1,190 @@
1
+ import os
2
+ import sys
3
+ import torch
4
+ import timm
5
+ from argparse import ArgumentParser
6
+ from fvcore.nn import FlopCountAnalysis
7
+ from halib import *
8
+ from halib.filetype import csvfile
9
+ from curriculum.utils.config import *
10
+ from curriculum.utils.model_helper import *
11
+
12
+
13
+ # ---------------------------------------------------------------------
14
+ # Argument Parser
15
+ # ---------------------------------------------------------------------
16
+ def parse_args():
17
+ parser = ArgumentParser(description="Calculate FLOPs for TIMM or trained models")
18
+
19
+ # Option 1: Direct TIMM model
20
+ parser.add_argument(
21
+ "--model_name", type=str, help="TIMM model name (e.g., efficientnet_b0)"
22
+ )
23
+ parser.add_argument(
24
+ "--num_classes", type=int, default=1000, help="Number of output classes"
25
+ )
26
+
27
+ # Option 2: Experiment directory
28
+ parser.add_argument(
29
+ "--indir",
30
+ type=str,
31
+ default=None,
32
+ help="Directory containing trained experiment (with .yaml and .pth)",
33
+ )
34
+ parser.add_argument(
35
+ "-o", "--o", action="store_true", help="Open output CSV after saving"
36
+ )
37
+ return parser.parse_args()
38
+
39
+
40
+ # ---------------------------------------------------------------------
41
+ # Helper Functions
42
+ # ---------------------------------------------------------------------
43
+ def _get_list_of_proc_dirs(indir):
44
+ assert os.path.exists(indir), f"Input directory {indir} does not exist."
45
+ pth_files = [f for f in os.listdir(indir) if f.endswith(".pth")]
46
+ if len(pth_files) > 0:
47
+ return [indir]
48
+ return [
49
+ os.path.join(indir, f)
50
+ for f in os.listdir(indir)
51
+ if os.path.isdir(os.path.join(indir, f))
52
+ ]
53
+
54
+
55
+ def _calculate_flops_for_model(model_name, num_classes):
56
+ """Calculate FLOPs for a plain TIMM model."""
57
+ try:
58
+ model = timm.create_model(model_name, pretrained=False, num_classes=num_classes)
59
+ input_size = timm.data.resolve_data_config(model.default_cfg)["input_size"]
60
+ dummy_input = torch.randn(1, *input_size)
61
+ model.eval() # ! set to eval mode to avoid some warnings or errors
62
+ flops = FlopCountAnalysis(model, dummy_input)
63
+ gflops = flops.total() / 1e9
64
+ mflops = flops.total() / 1e6
65
+ print(f"\nModel: **{model_name}**, Classes: {num_classes}")
66
+ print(f"Input size: {input_size}, FLOPs: **{gflops:.3f} GFLOPs**, **{mflops:.3f} MFLOPs**\n")
67
+ return model_name, gflops, mflops
68
+ except Exception as e:
69
+ print(f"[Error] Could not calculate FLOPs for {model_name}: {e}")
70
+ return model_name, -1, -1
71
+
72
+
73
+ def _calculate_flops_for_experiment(exp_dir):
74
+ """Calculate FLOPs for a trained experiment directory."""
75
+ yaml_files = [f for f in os.listdir(exp_dir) if f.endswith(".yaml")]
76
+ pth_files = [f for f in os.listdir(exp_dir) if f.endswith(".pth")]
77
+
78
+ assert (
79
+ len(yaml_files) == 1
80
+ ), f"Expected 1 YAML file in {exp_dir}, found {len(yaml_files)}"
81
+ assert (
82
+ len(pth_files) == 1
83
+ ), f"Expected 1 PTH file in {exp_dir}, found {len(pth_files)}"
84
+
85
+ exp_cfg_yaml = os.path.join(exp_dir, yaml_files[0])
86
+ cfg = ExpConfig.from_yaml(exp_cfg_yaml)
87
+ ds_label_list = cfg.dataset.get_label_list()
88
+
89
+ try:
90
+ model = build_model(
91
+ cfg.model.name, num_classes=len(ds_label_list), pretrained=True
92
+ )
93
+ model_weights_path = os.path.join(exp_dir, pth_files[0])
94
+ model.load_state_dict(torch.load(model_weights_path, map_location="cpu"))
95
+ model.eval()
96
+
97
+ input_size = timm.data.resolve_data_config(model.default_cfg)["input_size"]
98
+ dummy_input = torch.randn(1, *input_size)
99
+ flops = FlopCountAnalysis(model, dummy_input)
100
+ gflops = flops.total() / 1e9
101
+ mflops = flops.total() / 1e6
102
+
103
+ return str(cfg), cfg.model.name, gflops, mflops
104
+ except Exception as e:
105
+ console.print(f"[red] Error processing {exp_dir}: {e}[/red]")
106
+ return str(cfg), cfg.model.name, -1, -1
107
+
108
+
109
+ # ---------------------------------------------------------------------
110
+ # Main Entry
111
+ # ---------------------------------------------------------------------
112
+ def main():
113
+ args = parse_args()
114
+
115
+ # Case 1: Direct TIMM model input
116
+ if args.model_name:
117
+ _calculate_flops_for_model(args.model_name, args.num_classes)
118
+ return
119
+
120
+ # Case 2: Experiment directory input
121
+ if args.indir is None:
122
+ print("[Error] Either --model_name or --indir must be specified.")
123
+ return
124
+
125
+ proc_dirs = _get_list_of_proc_dirs(args.indir)
126
+ pprint(proc_dirs)
127
+
128
+ dfmk = csvfile.DFCreator()
129
+ TABLE_NAME = "model_flops_results"
130
+ dfmk.create_table(TABLE_NAME, ["exp_name", "model_name", "gflops", "mflops"])
131
+
132
+ console.rule(f"Calculating FLOPs for models in {len(proc_dirs)} dir(s)...")
133
+ rows = []
134
+ for exp_dir in tqdm(proc_dirs):
135
+ dir_name = os.path.basename(exp_dir)
136
+ console.rule(f"{dir_name}")
137
+ exp_name, model_name, gflops, mflops = _calculate_flops_for_experiment(exp_dir)
138
+ rows.append([exp_name, model_name, gflops, mflops])
139
+
140
+ dfmk.insert_rows(TABLE_NAME, rows)
141
+ dfmk.fill_table_from_row_pool(TABLE_NAME)
142
+
143
+ outfile = f"zout/zreport/{now_str()}_model_flops_results.csv"
144
+ dfmk[TABLE_NAME].to_csv(outfile, sep=";", index=False)
145
+ csvfile.fn_display_df(dfmk[TABLE_NAME])
146
+
147
+ if args.o:
148
+ os.system(f"start {outfile}")
149
+
150
+
151
+ # ---------------------------------------------------------------------
152
+ # Script Entry
153
+ # ---------------------------------------------------------------------
154
+ # flop_csv.py
155
+ # if __name__ == "__main__":
156
+ # sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
157
+ # main()
158
+
159
+
160
+ # def main():
161
+ # csv_file = "./results-imagenet.csv"
162
+ # df = pd.read_csv(csv_file)
163
+ # # make param_count column as float
164
+ # # df['param_count'] = df['param_count'].astype(float)
165
+ # df["param_count"] = (
166
+ # pd.to_numeric(df["param_count"], errors="coerce").fillna(99999).astype(float)
167
+ # )
168
+ # df = df[df["param_count"] < 5.0] # filter models with param_count < 20M
169
+
170
+ # dict_ls = []
171
+
172
+ # for index, row in tqdm(df.iterrows()):
173
+ # console.rule(f"Row {index+1}/{len(df)}")
174
+ # model = row["model"]
175
+ # num_class = 2
176
+ # _, _, mflops = _calculate_flops_for_model(model, num_class)
177
+ # dict_ls.append(
178
+ # {"model": model, "param_count": row["param_count"], "mflops": mflops}
179
+ # )
180
+
181
+ # # Create a DataFrame from the list of dictionaries
182
+ # result_df = pd.DataFrame(dict_ls)
183
+
184
+ # final_df = pd.merge(df, result_df, on=["model", "param_count"])
185
+ # final_df.sort_values(by="mflops", inplace=True, ascending=True)
186
+ # csvfile.fn_display_df(final_df)
187
+
188
+
189
+ # if __name__ == "__main__":
190
+ # main()
@@ -0,0 +1,58 @@
1
+ # install `pynvml_utils` package first
2
+ # see this repo: https://github.com/gpuopenanalytics/pynvml
3
+ from pynvml_utils import nvidia_smi
4
+ import time
5
+ import threading
6
+ from rich.pretty import pprint
7
+
8
+ class GPUMonitor:
9
+ def __init__(self, gpu_index=0, interval=0.01):
10
+ self.nvsmi = nvidia_smi.getInstance()
11
+ self.gpu_index = gpu_index
12
+ self.interval = interval
13
+ self.gpu_stats = []
14
+ self._running = False
15
+ self._thread = None
16
+
17
+ def _monitor(self):
18
+ while self._running:
19
+ stats = self.nvsmi.DeviceQuery("power.draw, memory.used")["gpu"][
20
+ self.gpu_index
21
+ ]
22
+ # pprint(stats)
23
+ self.gpu_stats.append(
24
+ {
25
+ "power": stats["power_readings"]["power_draw"],
26
+ "power_unit": stats["power_readings"]["unit"],
27
+ "memory": stats["fb_memory_usage"]["used"],
28
+ "memory_unit": stats["fb_memory_usage"]["unit"],
29
+ }
30
+ )
31
+ time.sleep(self.interval)
32
+
33
+ def start(self):
34
+ if not self._running:
35
+ self._running = True
36
+ # clear previous stats
37
+ self.gpu_stats.clear()
38
+ self._thread = threading.Thread(target=self._monitor)
39
+ self._thread.start()
40
+
41
+ def stop(self):
42
+ if self._running:
43
+ self._running = False
44
+ self._thread.join()
45
+ # clear the thread reference
46
+ self._thread = None
47
+
48
+ def get_stats(self):
49
+ ## return self.gpu_stats
50
+ assert self._running is False, "GPU monitor is still running. Stop it first."
51
+
52
+ powers = [s["power"] for s in self.gpu_stats if s["power"] is not None]
53
+ memories = [s["memory"] for s in self.gpu_stats if s["memory"] is not None]
54
+ avg_power = sum(powers) / len(powers) if powers else 0
55
+ max_memory = max(memories) if memories else 0
56
+ # power_unit = self.gpu_stats[0]["power_unit"] if self.gpu_stats else "W"
57
+ # memory_unit = self.gpu_stats[0]["memory_unit"] if self.gpu_stats else "MiB"
58
+ return {"gpu_avg_power": avg_power, "gpu_avg_max_memory": max_memory}
@@ -0,0 +1,363 @@
1
+ import os
2
+ import glob
3
+ from typing import Optional, Tuple
4
+ import pandas as pd
5
+
6
+ from abc import ABC, abstractmethod
7
+ from collections import OrderedDict
8
+
9
+
10
+ from ...common.common import now_str
11
+ from ...system import filesys as fs
12
+
13
+ from .perftb import PerfTB
14
+ from .perfmetrics import *
15
+
16
+
17
+ REQUIRED_COLS = ["experiment", "dataset"]
18
+ CSV_FILE_POSTFIX = "__perf"
19
+ METRIC_PREFIX = "metric_"
20
+
21
+
22
+ class PerfCalc(ABC): # Abstract base class for performance calculation
23
+ @abstractmethod
24
+ def get_experiment_name(self) -> str:
25
+ """
26
+ Return the name of the experiment.
27
+ This function should be overridden by the subclass if needed.
28
+ """
29
+ pass
30
+
31
+ @abstractmethod
32
+ def get_dataset_name(self) -> str:
33
+ """
34
+ Return the name of the dataset.
35
+ This function should be overridden by the subclass if needed.
36
+ """
37
+ pass
38
+
39
+ @abstractmethod
40
+ def get_metric_backend(self) -> MetricsBackend:
41
+ """
42
+ Return a list of metric names to be used for performance calculation OR a dictionaray with keys as metric names and values as metric instances of torchmetrics.Metric. For example: {"accuracy": Accuracy(), "precision": Precision()}
43
+
44
+ """
45
+ pass
46
+
47
+ def valid_proc_extra_data(self, proc_extra_data):
48
+ # make sure that all items in proc_extra_data are dictionaries, with same keys
49
+ if proc_extra_data is None or len(proc_extra_data) == 0:
50
+ return
51
+ if not all(isinstance(item, dict) for item in proc_extra_data):
52
+ raise TypeError("All items in proc_extra_data must be dictionaries")
53
+
54
+ if not all(
55
+ item.keys() == proc_extra_data[0].keys() for item in proc_extra_data
56
+ ):
57
+ raise ValueError(
58
+ "All dictionaries in proc_extra_data must have the same keys"
59
+ )
60
+
61
+ def valid_proc_metric_raw_data(self, metric_names, proc_metric_raw_data):
62
+ # make sure that all items in proc_metric_raw_data are dictionaries, with same keys as metric_names
63
+ assert (
64
+ isinstance(proc_metric_raw_data, list) and len(proc_metric_raw_data) > 0
65
+ ), "raw_data_for_metrics must be a non-empty list of dictionaries"
66
+
67
+ # make sure that all items in proc_metric_raw_data are dictionaries with keys as metric_names
68
+ if not all(isinstance(item, dict) for item in proc_metric_raw_data):
69
+ raise TypeError("All items in raw_data_for_metrics must be dictionaries")
70
+ if not all(
71
+ set(item.keys()) == set(metric_names) for item in proc_metric_raw_data
72
+ ):
73
+ raise ValueError(
74
+ "All dictionaries in raw_data_for_metrics must have the same keys as metric_names"
75
+ )
76
+
77
+ # ! only need to override this method if torchmetrics are not used
78
+ def calc_exp_perf_metrics(
79
+ self, metric_names, raw_metrics_data, extra_data=None, *args, **kwargs
80
+ ):
81
+ assert isinstance(raw_metrics_data, dict) or isinstance(
82
+ raw_metrics_data, list
83
+ ), "raw_data_for_metrics must be a dictionary or a list"
84
+
85
+ if extra_data is not None:
86
+ assert isinstance(
87
+ extra_data, type(raw_metrics_data)
88
+ ), "extra_data must be of the same type as raw_data_for_metrics (dict or list)"
89
+ # prepare raw_metric data for processing
90
+ proc_metric_raw_data_ls = (
91
+ raw_metrics_data
92
+ if isinstance(raw_metrics_data, list)
93
+ else [raw_metrics_data.copy()]
94
+ )
95
+ self.valid_proc_metric_raw_data(metric_names, proc_metric_raw_data_ls)
96
+ # prepare extra data for processing
97
+ proc_extra_data_ls = []
98
+ if extra_data is not None:
99
+ proc_extra_data_ls = (
100
+ extra_data if isinstance(extra_data, list) else [extra_data.copy()]
101
+ )
102
+ assert len(proc_extra_data_ls) == len(
103
+ proc_metric_raw_data_ls
104
+ ), "extra_data must have the same length as raw_data_for_metrics if it is a list"
105
+ # validate the extra_data
106
+ self.valid_proc_extra_data(proc_extra_data_ls)
107
+
108
+ # calculate the metrics output results
109
+ metrics_backend = self.get_metric_backend()
110
+ proc_outdict_list = []
111
+ for idx, raw_metrics_data in enumerate(proc_metric_raw_data_ls):
112
+ out_dict = {
113
+ "dataset": self.get_dataset_name(),
114
+ "experiment": self.get_experiment_name(),
115
+ }
116
+ custom_fields = []
117
+ if len(proc_extra_data_ls) > 0:
118
+ # add extra data to the output dictionary
119
+ extra_data_item = proc_extra_data_ls[idx]
120
+ out_dict.update(extra_data_item)
121
+ custom_fields = list(extra_data_item.keys())
122
+ metric_results = metrics_backend.calc_metrics(
123
+ metrics_data_dict=raw_metrics_data, *args, **kwargs
124
+ )
125
+ metric_results_prefix = {
126
+ f"metric_{k}": v for k, v in metric_results.items()
127
+ }
128
+ out_dict.update(metric_results_prefix)
129
+ ordered_cols = (
130
+ REQUIRED_COLS + custom_fields + list(metric_results_prefix.keys())
131
+ )
132
+ out_dict = OrderedDict(
133
+ (col, out_dict[col]) for col in ordered_cols if col in out_dict
134
+ )
135
+ proc_outdict_list.append(out_dict)
136
+
137
+ return proc_outdict_list
138
+
139
+ #! custom kwargs:
140
+ #! outfile - if provided, will save the output to a CSV file with the given path
141
+ #! outdir - if provided, will save the output to a CSV file in the given directory with a generated filename
142
+ #! return_df - if True, will return a DataFrame instead of a dictionary
143
+ def calc_perfs(
144
+ self,
145
+ raw_metrics_data: Union[List[dict], dict],
146
+ extra_data: Optional[Union[List[dict], dict]] = None,
147
+ *args,
148
+ **kwargs,
149
+ ) -> Tuple[Union[List[OrderedDict], pd.DataFrame], Optional[str]]:
150
+ """
151
+ Calculate the metrics.
152
+ This function should be overridden by the subclass if needed.
153
+ Must return a dictionary with keys as metric names and values as the calculated metrics.
154
+ """
155
+ metric_names = self.get_metric_backend().metric_names
156
+ out_dict_list = self.calc_exp_perf_metrics(
157
+ metric_names=metric_names,
158
+ raw_metrics_data=raw_metrics_data,
159
+ extra_data=extra_data,
160
+ *args,
161
+ **kwargs,
162
+ )
163
+ csv_outfile = kwargs.get("outfile", None)
164
+ if csv_outfile is not None:
165
+ filePathNoExt, _ = os.path.splitext(csv_outfile)
166
+ # pprint(f"CSV Outfile Path (No Ext): {filePathNoExt}")
167
+ csv_outfile = f"{filePathNoExt}{CSV_FILE_POSTFIX}.csv"
168
+ elif "outdir" in kwargs:
169
+ csvoutdir = kwargs["outdir"]
170
+ csvfilename = f"{now_str()}_{self.get_dataset_name()}_{self.get_experiment_name()}_{CSV_FILE_POSTFIX}.csv"
171
+ csv_outfile = os.path.join(csvoutdir, csvfilename)
172
+
173
+ # convert out_dict to a DataFrame
174
+ df = pd.DataFrame(out_dict_list)
175
+ # get the orders of the columns as the orders or the keys in out_dict
176
+ ordered_cols = list(out_dict_list[0].keys())
177
+ df = df[ordered_cols] # reorder columns
178
+ if csv_outfile:
179
+ df.to_csv(csv_outfile, index=False, sep=";", encoding="utf-8")
180
+ return_df = kwargs.get("return_df", False)
181
+ if return_df: # return DataFrame instead of dict if requested
182
+ return df, csv_outfile
183
+ else:
184
+ return out_dict_list, csv_outfile
185
+
186
+ @staticmethod
187
+ def default_exp_csv_filter_fn(exp_file_name: str) -> bool:
188
+ """
189
+ Default filter function for experiments.
190
+ Returns True if the experiment name does not start with "test_" or "debug_".
191
+ """
192
+ return "__perf.csv" in exp_file_name
193
+
194
+ @classmethod
195
+ def get_perftb_for_multi_exps(
196
+ cls,
197
+ indir: str,
198
+ exp_csv_filter_fn=default_exp_csv_filter_fn,
199
+ include_file_name=False,
200
+ csv_sep=";",
201
+ ) -> PerfTB:
202
+ """
203
+ Generate a performance report by scanning experiment subdirectories.
204
+ Must return a dictionary with keys as metric names and values as performance tables.
205
+ """
206
+
207
+ def get_df_for_all_exp_perf(csv_perf_files, csv_sep=";"):
208
+ """
209
+ Create a single DataFrame from all CSV files.
210
+ Assumes all CSV files MAY have different metrics
211
+ """
212
+ cols = []
213
+ FILE_NAME_COL = "file_name" if include_file_name else None
214
+
215
+ for csv_file in csv_perf_files:
216
+ temp_df = pd.read_csv(csv_file, sep=csv_sep)
217
+ if FILE_NAME_COL:
218
+ temp_df[FILE_NAME_COL] = fs.get_file_name(
219
+ csv_file, split_file_ext=False
220
+ )
221
+ # csvfile.fn_display_df(temp_df)
222
+ temp_df_cols = temp_df.columns.tolist()
223
+ for col in temp_df_cols:
224
+ if col not in cols:
225
+ cols.append(col)
226
+
227
+ df = pd.DataFrame(columns=cols)
228
+ for csv_file in csv_perf_files:
229
+ temp_df = pd.read_csv(csv_file, sep=csv_sep)
230
+ if FILE_NAME_COL:
231
+ temp_df[FILE_NAME_COL] = fs.get_file_name(
232
+ csv_file, split_file_ext=False
233
+ )
234
+ # Drop all-NA columns to avoid dtype inconsistency
235
+ temp_df = temp_df.dropna(axis=1, how="all")
236
+ # ensure all columns are present in the final DataFrame
237
+ for col in cols:
238
+ if col not in temp_df.columns:
239
+ temp_df[col] = None # fill missing columns with None
240
+ df = pd.concat([df, temp_df], ignore_index=True)
241
+ # assert that REQUIRED_COLS are present in the DataFrame
242
+ # pprint(df.columns.tolist())
243
+ sticky_cols = REQUIRED_COLS + (
244
+ [FILE_NAME_COL] if include_file_name else []
245
+ ) # columns that must always be present
246
+ for col in sticky_cols:
247
+ if col not in df.columns:
248
+ raise ValueError(
249
+ f"Required column '{col}' is missing from the DataFrame. REQUIRED_COLS = {sticky_cols}"
250
+ )
251
+ metric_cols = [col for col in df.columns if col.startswith(METRIC_PREFIX)]
252
+ assert (
253
+ len(metric_cols) > 0
254
+ ), "No metric columns found in the DataFrame. Ensure that the CSV files contain metric columns starting with 'metric_'."
255
+ final_cols = sticky_cols + metric_cols
256
+ df = df[final_cols]
257
+ # # !hahv debug
258
+ # pprint("------ Final DataFrame Columns ------")
259
+ # csvfile.fn_display_df(df)
260
+ # ! validate all rows in df before returning
261
+ # make sure all rows will have at least values for REQUIRED_COLS and at least one metric column
262
+ for index, row in df.iterrows():
263
+ if not all(col in row and pd.notna(row[col]) for col in sticky_cols):
264
+ raise ValueError(
265
+ f"Row {index} is missing required columns or has NaN values in required columns: {row}"
266
+ )
267
+ if not any(pd.notna(row[col]) for col in metric_cols):
268
+ raise ValueError(f"Row {index} has no metric values: {row}")
269
+ # make sure these is no (experiment, dataset) pair that is duplicated
270
+ duplicates = df.duplicated(subset=sticky_cols, keep=False)
271
+ if duplicates.any():
272
+ raise ValueError(
273
+ "Duplicate (experiment, dataset) pairs found in the DataFrame. Please ensure that each experiment-dataset combination is unique."
274
+ )
275
+ return df
276
+
277
+ def mk_perftb_report(df):
278
+ """
279
+ Create a performance report table from the DataFrame.
280
+ This function should be customized based on the specific requirements of the report.
281
+ """
282
+ perftb = PerfTB()
283
+ # find all "dataset" values (unique)
284
+ dataset_names = list(df["dataset"].unique())
285
+ # find all columns that start with METRIC_PREFIX
286
+ metric_cols = [col for col in df.columns if col.startswith(METRIC_PREFIX)]
287
+
288
+ # Determine which metrics are associated with each dataset.
289
+ # Since a dataset may appear in multiple rows and may not include all metrics in each, identify the row with the same dataset that contains the most non-NaN metric values. The set of metrics for that dataset is defined by the non-NaN metrics in that row.
290
+
291
+ dataset_metrics = {}
292
+ for dataset_name in dataset_names:
293
+ dataset_rows = df[df["dataset"] == dataset_name]
294
+ # Find the row with the most non-NaN metric values
295
+ max_non_nan_row = dataset_rows[metric_cols].count(axis=1).idxmax()
296
+ metrics_for_dataset = (
297
+ dataset_rows.loc[max_non_nan_row, metric_cols]
298
+ .dropna()
299
+ .index.tolist()
300
+ )
301
+ dataset_metrics[dataset_name] = metrics_for_dataset
302
+
303
+ for dataset_name, metrics in dataset_metrics.items():
304
+ # Create a new row for the performance table
305
+ perftb.add_dataset(dataset_name, metrics)
306
+
307
+ for _, row in df.iterrows():
308
+ dataset_name = row["dataset"]
309
+ ds_metrics = dataset_metrics.get(dataset_name)
310
+ if dataset_name in dataset_metrics:
311
+ # Add the metrics for this row to the performance table
312
+ exp_name = row.get("experiment")
313
+ exp_metric_values = {}
314
+ for metric in ds_metrics:
315
+ if metric in row and pd.notna(row[metric]):
316
+ exp_metric_values[metric] = row[metric]
317
+ perftb.add_experiment(
318
+ experiment_name=exp_name,
319
+ dataset_name=dataset_name,
320
+ metrics=exp_metric_values,
321
+ )
322
+
323
+ return perftb
324
+
325
+ assert os.path.exists(indir), f"Input directory {indir} does not exist."
326
+
327
+ csv_perf_files = []
328
+ # Find experiment subdirectories
329
+ exp_dirs = [
330
+ os.path.join(indir, d)
331
+ for d in os.listdir(indir)
332
+ if os.path.isdir(os.path.join(indir, d))
333
+ ]
334
+ if len(exp_dirs) == 0:
335
+ csv_perf_files = glob.glob(os.path.join(indir, f"*.csv"))
336
+ csv_perf_files = [
337
+ file_item
338
+ for file_item in csv_perf_files
339
+ if exp_csv_filter_fn(file_item)
340
+ ]
341
+ else:
342
+ # multiple experiment directories found
343
+ # Collect all matching CSV files in those subdirs
344
+ for exp_dir in exp_dirs:
345
+ # pprint(f"Searching in experiment directory: {exp_dir}")
346
+ matched = glob.glob(os.path.join(exp_dir, f"*.csv"))
347
+ matched = [
348
+ file_item for file_item in matched if exp_csv_filter_fn(file_item)
349
+ ]
350
+ csv_perf_files.extend(matched)
351
+
352
+ assert (
353
+ len(csv_perf_files) > 0
354
+ ), f"No CSV files matching pattern '{exp_csv_filter_fn}' found in the experiment directories."
355
+
356
+ assert (
357
+ len(csv_perf_files) > 0
358
+ ), f"No CSV files matching pattern '{exp_csv_filter_fn}' found in the experiment directories."
359
+
360
+ all_exp_perf_df = get_df_for_all_exp_perf(csv_perf_files, csv_sep=csv_sep)
361
+ # csvfile.fn_display_df(all_exp_perf_df)
362
+ perf_tb = mk_perftb_report(all_exp_perf_df)
363
+ return perf_tb