Skip to content

Commit 3defb8a

Browse files
Revisions to score.py
1 parent 6dd59c0 commit 3defb8a

7 files changed

Lines changed: 804 additions & 475 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ __pycache__/
88

99
# C extensions
1010
*.so
11+
*.py.sav
12+
err_*
1113

1214
# Distribution / packaging
1315
.Python

scripts/calculate_linear_prediction_scores.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def processModels(first_model_num: int, last_model_num: int,
146146
threshold=threshold)
147147
discovery.fit()
148148
prediction_df = discovery.predict()
149-
score.add(timecourse.timecourse_df, prediction_df, label=model_name)
149+
score.add(timecourse.timecourse_df, prediction_df, system_id=model_name)
150150
except Exception as e:
151151
print(f"Error occurred while processing model {model_name}: {e}")
152152
continue

src/constants.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@
3939
COL_NUM_PERTURBATION = "num_perturbation"
4040
COL_NUM_TIMEPOINT = "num_timepoint"
4141
COL_NAMES = [COL_MODEL_NAME, COL_MAXCV, COL_ENDTIME, COL_ENDTIME_SOURCE]
42+
COL_SYSTEM_ID = "system_id" # Unique identifier for the system, e.g. model name or species name.
43+
COL_MEAN = "mean" # Mean value of the valid values used in calculating the statistics.
44+
COL_MIN = "min" # Minimum value of the valid values used in calculating the statistics.
45+
COL_MAX = "max" # Maximum value of the valid values used in calculating the statistics.
46+
COL_COUNT = "count" # Number of valid values used in calculating the statistics.
47+
COL_INVALID_COUNT = "invalid_count" # Number of invalid (sentinel -1) values excluded from aggregation.
48+
COL_LABEL = "label" # Unique identifier for the row of statistics, e.g. model name or species name.
49+
COL_P25 = "p25" # 25th percentile of the valid values used in calculating the statistics.
50+
COL_P30 = "p30" # 30th percentile of the valid values used in calculating the statistics.
51+
COL_P50 = "p50" # 50th percentile of the valid values used in calculating the statistics.
52+
COL_P80 = "p80" # 80th percentile of the valid values used in calculating the statistics.
53+
COL_P95 = "p95" # 95th percentile of the valid values used in calculating the statistics.
54+
COL_P99 = "p99" # 99th percentile of the valid values used
55+
COL_PERCENTILES = [COL_P25, COL_P30, COL_P50, COL_P80, COL_P95, COL_P99]
56+
STATISTICS = [COL_MEAN, COL_MIN, COL_MAX, COL_COUNT, COL_INVALID_COUNT] + COL_PERCENTILES
4257

4358
# Symbolic values
4459
ENDTIME_SOURCE_RECIROCAL_MIN_EIGENVALUE = "reciprocal_min_eigenvalue"

src/score.py

Lines changed: 20 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -7,106 +7,15 @@
77
MAPE = max(0, 1 - (abs(prediction - true) / true))
88
"""
99

10-
from src.dataframe_serializer import DataframeSerializer # type: ignore
1110
from src.plot_options import PlotOptions # type: ignore
1211
import src.constants as cn # type: ignore
12+
from src.statistic_calculator import StatisticCalculator # type: ignore
1313

1414
import matplotlib.pyplot as plt # type: ignore
1515
import numpy as np # type: ignore
1616
import pandas as pd # type: ignore
1717

18-
MEAN = "mean"
19-
MIN = "min"
20-
MAX = "max"
21-
COUNT = "count"
2218

23-
#########################################
24-
class _StatisticAccumulator(object):
25-
STATISTICS = ["mean", "min", "max", "count", "invalid_count", "p25", "p30",
26-
"p50", "p80", "p95", "p99"]
27-
# count is the number of valid values used in calculating the statistics.
28-
# invalid_count is the number of invalid (sentinel -1) values excluded from aggregation.
29-
30-
"""A container for storing statistics accumulated in a dictionary"""
31-
def __init__(self) -> None:
32-
self.statistic_dct: dict = {n: [] for n in self.STATISTICS}
33-
self.statistic_dct[cn.AGGREGATION_TYPE] = []
34-
self.statistic_dct[cn.DESCRIPTION] = []
35-
36-
def add(self,
37-
value_arr: np.ndarray = np.array([]),
38-
label: str = "",
39-
aggregation_type: str = "", # "model" or species name
40-
) -> None:
41-
"""Computes statistics and adds to cumulative values.
42-
43-
Parameters
44-
----------
45-
value_arr : np.ndarray
46-
1D Array of MAPE metric values. Negative sentinel values (-1)
47-
indicate invalid measurements and are excluded from aggregation.
48-
label : str
49-
Descriptive label for this aggregation.
50-
aggregation_type : str
51-
Type of aggregation, either "model" or a species name.
52-
53-
Returns
54-
-------
55-
None
56-
Modifies internal state (self.statistic_dct) only.
57-
"""
58-
value_arr = value_arr.flatten() # Flatten to 1D for aggregation
59-
LARGE_VAL = 1e6
60-
# Filter out negative sentinel values (invalid/undefined true values).
61-
valid_mask = value_arr >= 0
62-
invalid_count = int(np.sum(~valid_mask))
63-
valid_arr = value_arr[valid_mask].copy()
64-
65-
count = int(len(valid_arr))
66-
67-
# If no input values at all, still record a row so the CSV reflects that data was collected.
68-
if len(value_arr) == 0:
69-
self.statistic_dct[cn.DESCRIPTION].append(label)
70-
self.statistic_dct[cn.AGGREGATION_TYPE].append(aggregation_type)
71-
self.statistic_dct[MEAN].append(0.0)
72-
self.statistic_dct[MIN].append(0.0)
73-
self.statistic_dct[MAX].append(0.0)
74-
self.statistic_dct[COUNT].append(0)
75-
self.statistic_dct["invalid_count"].append(invalid_count)
76-
for p in [p for p in self.STATISTICS if p.startswith("p")]:
77-
self.statistic_dct[p].append(0.0)
78-
return
79-
80-
# If all values are invalid, still record a row so the CSV reflects that data was collected.
81-
if count == 0:
82-
self.statistic_dct[cn.DESCRIPTION].append(label)
83-
self.statistic_dct[cn.AGGREGATION_TYPE].append(aggregation_type)
84-
self.statistic_dct[MEAN].append(0.0)
85-
self.statistic_dct[MIN].append(0.0)
86-
self.statistic_dct[MAX].append(0.0)
87-
self.statistic_dct[COUNT].append(0)
88-
self.statistic_dct["invalid_count"].append(invalid_count)
89-
for p in [p for p in self.STATISTICS if p.startswith("p")]:
90-
self.statistic_dct[p].append(0.0)
91-
return
92-
93-
# Replace remaining NaN/inf/large values with LARGE_VAL for aggregation.
94-
sel = np.isnan(valid_arr) | np.isinf(valid_arr) | (valid_arr > LARGE_VAL)
95-
valid_arr[sel] = LARGE_VAL
96-
# Update dictionary
97-
self.statistic_dct[cn.DESCRIPTION].append(label)
98-
self.statistic_dct[cn.AGGREGATION_TYPE].append(aggregation_type)
99-
self.statistic_dct[MEAN].append(float(np.nanmean(valid_arr)))
100-
self.statistic_dct[MIN].append(float(np.nanmin(valid_arr)))
101-
self.statistic_dct[MAX].append(float(np.nanmax(valid_arr)))
102-
self.statistic_dct[COUNT].append(count)
103-
self.statistic_dct["invalid_count"].append(invalid_count)
104-
percentiles = [p for p in self.STATISTICS if p.startswith("p")]
105-
for p in percentiles:
106-
self.statistic_dct[p].append(float(np.nanpercentile(valid_arr, int(p[1:]))))
107-
108-
109-
#########################################
11019
class Score:
11120
"""Scores prediction timecourses against true timecourses using zero-floor MAPE.
11221
@@ -129,20 +38,17 @@ def __init__(self, serialization_path: str = "",
12938
is_persist : bool
13039
Whether to persist the DataFrame to the CSV file.
13140
"""
41+
self._is_persist = is_persist
13242
if len(serialization_path) == 0:
13343
serialization_path = self.SERIALIZATION_PATH
134-
self._serializer = DataframeSerializer(serialization_path,
135-
is_initialize=is_initialize, is_persist=is_persist)
44+
self._serialization_path = serialization_path
13645
#
137-
self.statistic_accumulator = _StatisticAccumulator()
46+
self.score_df = pd.DataFrame()
47+
self.statistic_calculator = StatisticCalculator()
13848

13949
@property
14050
def serialization_path(self) -> str:
141-
return self._serializer.serialization_path
142-
143-
@property
144-
def score_df(self) -> pd.DataFrame:
145-
return self._serializer.dataframe
51+
return self._serialization_path
14652

14753
@staticmethod
14854
def calculateMAPE(true_df: pd.DataFrame,
@@ -180,14 +86,14 @@ def calculateMAPE(true_df: pd.DataFrame,
18086
def add(self,
18187
true_timecourse_df: pd.DataFrame,
18288
prediction_timecourse_df: pd.DataFrame,
183-
label: str = "",
89+
system_id: str = "",
18490
) -> pd.DataFrame:
185-
"""Computes MAPE scores and accumulates statistics for model-level and per-species aggregations.
91+
"""Computes scores and accumulates statistics for model-level and per-species aggregations.
18692
18793
Parameters
18894
----------
189-
label : str
190-
Descriptive label stored in each aggregation row.
95+
system_id : str
96+
Descriptive ID stored in each aggregation row.
19197
true_timecourse_df : pd.DataFrame
19298
True timecourse with timepoints as index and species as columns.
19399
prediction_timecourse_df : pd.DataFrame
@@ -198,38 +104,18 @@ def add(self,
198104
pd.DataFrame
199105
The full score DataFrame after this addition.
200106
"""
201-
score_df: pd.DataFrame = self.calculateMAPE(true_timecourse_df, prediction_timecourse_df)
202-
203-
# Record how many rows were in the accumulator BEFORE this call.
204-
dct = self.statistic_accumulator.statistic_dct
205-
start_count = len(dct[cn.DESCRIPTION])
206-
207-
# Model level aggregation (all species and timepoints combined)
208-
model_arr = np.asarray(score_df.values, dtype=float)
209-
self.statistic_accumulator.add(model_arr,
210-
aggregation_type=cn.AGGREGATION_TYPE_MODEL,
211-
label=label)
212-
107+
mape_df: pd.DataFrame = self.calculateMAPE(true_timecourse_df, prediction_timecourse_df)
108+
self.statistic_calculator.add(cn.AGGREGATION_TYPE_MODEL, mape_df.values.flatten())
213109
# Species level aggregations (one per species column, across all timepoints)
214-
species_names = list(score_df.columns)
110+
species_names = list(mape_df.columns)
215111
for species_name in species_names:
216-
species_arr = np.asarray(score_df[species_name].values, dtype=float)
217-
self.statistic_accumulator.add(species_arr,
218-
aggregation_type=species_name,
219-
label=label)
220-
221-
# Build list of dicts from only the NEW rows added in this call.
222-
end_count = len(dct[cn.DESCRIPTION])
223-
new_rows: list[dict] = []
224-
for i in range(start_count, end_count):
225-
row: dict = {}
226-
for key in dct:
227-
if isinstance(dct[key], list):
228-
row[key] = dct[key][i]
229-
new_rows.append(row)
230-
231-
# Persist the new rows to the DataFrame/CSV.
232-
self._serializer.serializeDct(new_rows)
112+
self.statistic_calculator.add(species_name, mape_df[species_name].to_numpy())
113+
# Add the system ID
114+
self.score_df = self.statistic_calculator.dataframe.copy()
115+
self.score_df[cn.COL_SYSTEM_ID] = system_id
116+
# Serialize the accumulated statistics
117+
if self._is_persist:
118+
self.score_df.to_csv(self.serialization_path, index=False)
233119
return self.score_df
234120

235121
def plotCDF(self,

src/statistic_calculator.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'''Calculates univariate descriptive statistics'''
2+
3+
# Statistics that begin with 'p' are percentiles, e.g. p25 is the 25th percentile
4+
# of the valid values used in calculating the statistics.
5+
# Assumes that all statistics
6+
"""
7+
Usage:
8+
statistics = StatisticCalculator()
9+
statistics.add(label="model1", value_arr=np.array([1, 2, 3, -1, 4]))
10+
statistics.add(label="model2", value_arr=np.array([5, 6, 7, 8, 9]))
11+
df = statistics.dataframe
12+
"""
13+
14+
15+
import src.constants as cn # type: ignore
16+
17+
import numpy as np # type: ignore
18+
import pandas as pd # type: ignore
19+
20+
LARGE_VAL = 1e6
21+
22+
23+
#########################################
24+
class StatisticCalculator(object):
25+
# count is the number of valid values used in calculating the statistics.
26+
# invalid_count is the number of invalid (sentinel -1) values excluded from aggregation.
27+
28+
"""A container for storing statistics accumulated in a dictionary"""
29+
def __init__(self) -> None:
30+
self.statistic_dct: dict = {n: [] for n in cn.STATISTICS}
31+
self.statistic_dct[cn.COL_LABEL] = []
32+
33+
@property
34+
def dataframe(self) -> pd.DataFrame:
35+
"""Returns a DataFrame of the accumulated statistics.
36+
37+
Returns
38+
-------
39+
pd.DataFrame
40+
DataFrame containing the accumulated statistics.
41+
"""
42+
return pd.DataFrame(self.statistic_dct)
43+
44+
def _is_percentile(self, stat_name: str) -> bool:
45+
"""Returns True if the statistic name is a percentile (e.g. p25, p50, etc.)"""
46+
return stat_name.startswith("p") and stat_name[1:].isdigit() and 0 <= int(stat_name[1:]) <= 100
47+
48+
def add(self,
49+
label: str,
50+
value_arr: np.ndarray = np.array([]),
51+
is_non_negative: bool = True,
52+
) -> None:
53+
"""Computes statistics and accumulates them in the internal dictionary (self.statistic_dct).
54+
55+
Parameters
56+
----------
57+
label : str
58+
Descriptive label for this aggregation.
59+
value_arr : np.ndarray
60+
1D Array of values. Negative sentinel values (-1)
61+
indicate invalid measurements and are excluded from aggregation.
62+
is_non_negative : bool
63+
If True, only non-negative values are considered valid for aggregation.
64+
65+
Returns
66+
-------
67+
None
68+
Modifies internal state (self.statistic_dct) only.
69+
"""
70+
value_arr = value_arr.flatten() # Flatten to 1D for aggregation
71+
# Filter out NaN, inf, and optionally negative values.
72+
if is_non_negative:
73+
valid_mask = [not (np.isnan(v) or np.isinf(v)) and v >= 0 for v in value_arr]
74+
else:
75+
valid_mask = [not (np.isnan(v) or np.isinf(v)) for v in value_arr]
76+
count = int(np.sum(valid_mask)) # Count of valid values used in aggregation
77+
invalid_count = len(value_arr) - int(np.sum(valid_mask))
78+
79+
# If no input values at all, still record a row so the CSV reflects that data was collected.
80+
if (len(value_arr) == 0) or (count == 0):
81+
self.statistic_dct[cn.COL_LABEL].append(label)
82+
self.statistic_dct[cn.COL_MEAN].append(np.nan)
83+
self.statistic_dct[cn.COL_MIN].append(np.nan)
84+
self.statistic_dct[cn.COL_MAX].append(np.nan)
85+
self.statistic_dct[cn.COL_COUNT].append(0)
86+
self.statistic_dct[cn.COL_INVALID_COUNT].append(invalid_count)
87+
for p in [p for p in cn.STATISTICS if self._is_percentile(p)]:
88+
self.statistic_dct[p].append(np.nan)
89+
return
90+
91+
# Replace large values with LARGE_VAL for aggregation.
92+
valid_arr = value_arr[valid_mask].copy()
93+
sel = valid_arr > LARGE_VAL
94+
valid_arr[sel] = LARGE_VAL
95+
# Update dictionary
96+
self.statistic_dct[cn.COL_LABEL].append(label)
97+
self.statistic_dct[cn.COL_MEAN].append(float(np.nanmean(valid_arr)))
98+
self.statistic_dct[cn.COL_MIN].append(float(np.nanmin(valid_arr)))
99+
self.statistic_dct[cn.COL_MAX].append(float(np.nanmax(valid_arr)))
100+
self.statistic_dct[cn.COL_COUNT].append(count)
101+
self.statistic_dct[cn.COL_INVALID_COUNT].append(invalid_count)
102+
percentiles = [p for p in cn.STATISTICS if self._is_percentile(p)]
103+
for p in percentiles:
104+
self.statistic_dct[p].append(float(np.nanpercentile(valid_arr, int(p[1:]))))

0 commit comments

Comments
 (0)