@@ -27,7 +27,7 @@ class BinaryBuilder(PiezoExporter):
2727 Required columns: ['UNIQUEID', 'MUTATION']
2828 Optional columns: ['FRS']
2929
30- FRS (float, optional): The Fraction Read Support threshold used to construct the catalogues.
30+ frs (float, optional): The Fraction Read Support threshold used to construct the catalogues.
3131 Lower FRS values allow for greater genotype heterogeneity.
3232
3333 seed (list) optional): A list of predefined GARC neutral mutations with associated phenotypes
@@ -140,10 +140,36 @@ def build(
140140 # If no more susceptible solos, classify all R and U solos in one, final sweep
141141 self .classify (self .samples , self .mutations )
142142
143+ # add in rows for mutations without solos
144+ # self.add_missing()
145+
143146 self .order_catalogue ()
144147
145148 return self
146149
150+ def add_missing (self ):
151+
152+ # add in remaining mutations
153+ all_muts = set (self .mutations .MUTATION .dropna ().unique ())
154+ classified_muts = set (self .catalogue .keys ())
155+ missing = all_muts - classified_muts
156+
157+ for mut in missing :
158+ full_x = self .build_full_contingency (self .samples , self .mutations , mut )
159+
160+ proportion = self .calc_proportion (full_x )
161+ ci = self .calc_confidence_interval (full_x )
162+
163+ data = {
164+ "proportion" : proportion ,
165+ "confidence" : ci ,
166+ "full_contingency" : full_x ,
167+ "solo_contingency" : None , # explicitly absent
168+ "note" : "No solo evidence; added post hoc" ,
169+ }
170+
171+ self .add_mutation (mut , "U" , data )
172+
147173 def classify (self , samples : pd .DataFrame , mutations : pd .DataFrame ) -> None :
148174 """
149175 Orchestrate one classification iteration over exposed 'solo' mutations.
@@ -196,22 +222,26 @@ def _process_solos(self, solos: pd.DataFrame, mut: str) -> None:
196222 # Skip mutations with fewer than min_count samples
197223 if solos [solos .MUTATION == mut ].shape [0 ] < self .min_count :
198224 return
199- # build a contingency table
200- x , ids = self .build_contingency (solos , mut )
225+ # build a contingency table for solo mutations
226+ x , ids = self .build_solo_contingency (solos , mut )
227+ # build a contingency table for all observations
228+ full_x = self .build_full_contingency (self .samples , self .mutations , mut )
201229 # temporarily store mutation groups:
202230 self .temp_ids = ids
203231
204232 # classify susceptible variants according to specified test mode
205233 if self .test is None :
206- self .skeleton_build (mut , x )
234+ self .skeleton_build (mut , x , full_x )
207235 elif self .test == "Binomial" :
208- self .binomial_build (mut , x )
236+ self .binomial_build (mut , x , full_x )
209237 elif self .test == "Fisher" :
210- self .fishers_build (mut , x )
238+ self .fishers_build (mut , x , full_x )
211239 else :
212240 raise ValueError (f"Unknown test mode: { self .test } " )
213241
214- def skeleton_build (self , mutation : str , x : Contingency ) -> None :
242+ def skeleton_build (
243+ self , mutation : str , x : Contingency , full_x : Contingency
244+ ) -> None :
215245 """
216246 Record descriptive statistics and optionally mark susceptible solos.
217247 Calls homogenous susceptible S.
@@ -227,7 +257,12 @@ def skeleton_build(self, mutation: str, x: Contingency) -> None:
227257 proportion = self .calc_proportion (x )
228258 ci = self .calc_confidence_interval (x )
229259
230- data = {"proportion" : proportion , "confidence" : ci , "contingency" : x }
260+ data = {
261+ "proportion" : proportion ,
262+ "confidence" : ci ,
263+ "solo_contingency" : x ,
264+ "contingency" : full_x ,
265+ }
231266
232267 if self .run_iter :
233268 # if iteratively classifing S variants
@@ -238,8 +273,12 @@ def skeleton_build(self, mutation: str, x: Contingency) -> None:
238273 # not phenotyping, just adding to catalogue
239274 self .add_mutation (mutation , "U" , data )
240275
241- def binomial_build (self , mutation : str , x : Contingency ) -> None :
242- assert self .background is not None , "background must be provided for Binomial test"
276+ def binomial_build (
277+ self , mutation : str , x : Contingency , full_x : Contingency
278+ ) -> None :
279+ assert (
280+ self .background is not None
281+ ), "background must be provided for Binomial test"
243282 bg : float = float (self .background )
244283
245284 # p-value function for binomial
@@ -258,10 +297,11 @@ def susceptible_rule(proportion: float, p_calc: float, x) -> bool:
258297 def resistant_rule (proportion : float , p_calc : float , x ) -> bool :
259298 return bool (proportion > bg )
260299
261- self .hypothesis_test (mutation , x , pvalue_fn , susceptible_rule , resistant_rule )
262-
300+ self .hypothesis_test (
301+ mutation , x , full_x , pvalue_fn , susceptible_rule , resistant_rule
302+ )
263303
264- def fishers_build (self , mutation : str , x : Contingency ) -> None :
304+ def fishers_build (self , mutation : str , x : Contingency , full_x : Contingency ) -> None :
265305 """
266306 Classify mutation using Fisher's exact test and directional inference.
267307
@@ -291,12 +331,15 @@ def resistant_rule(proportion, p_calc, x) -> bool:
291331 odds = self .calc_odds_ratio (x )
292332 return odds > 1
293333
294- self .hypothesis_test (mutation , x , pvalue_fn , susceptible_rule , resistant_rule )
334+ self .hypothesis_test (
335+ mutation , x , full_x , pvalue_fn , susceptible_rule , resistant_rule
336+ )
295337
296338 def hypothesis_test (
297339 self ,
298340 mutation : str ,
299341 x : Contingency ,
342+ full_x : Contingency ,
300343 pvalue_fn : Callable [[Contingency ], float ],
301344 susceptible_rule : Callable [[float , float , Contingency ], bool ],
302345 resistant_rule : Callable [[float , float , Contingency ], bool ],
@@ -324,7 +367,8 @@ def hypothesis_test(
324367 "proportion" : proportion ,
325368 "confidence" : ci ,
326369 "p_value" : p_calc ,
327- "contingency" : x ,
370+ "solo_contingency" : x , # solo-based (classification)
371+ "contingency" : full_x , # (descriptive only)
328372 }
329373
330374 # ITERATIVE MODE (we actively try to find susceptibles)
@@ -414,7 +458,7 @@ def calc_confidence_interval(self, x: Contingency) -> Tuple[float, float]:
414458 return (lower , upper )
415459
416460 @staticmethod
417- def build_contingency (
461+ def build_solo_contingency (
418462 solos : pd .DataFrame , mutation : str
419463 ) -> Tuple [list [list [int ]], list [str ]]:
420464 """
@@ -438,6 +482,30 @@ def build_contingency(
438482
439483 return [[R_count , S_count ], [R_count_no_mut , S_count_no_mut ]], ids
440484
485+ @staticmethod
486+ def build_full_contingency (
487+ samples : pd .DataFrame , mutations : pd .DataFrame , mutation : str
488+ ) -> list [list [int ]]:
489+ """
490+ Full contingency using ALL samples (sample-level, not row-level).
491+ """
492+
493+ # determine which samples have the mutation
494+ mut_present = mutations [mutations .MUTATION == mutation ][
495+ "UNIQUEID"
496+ ].drop_duplicates ()
497+
498+ df = samples .copy ()
499+ df ["has_mut" ] = df ["UNIQUEID" ].isin (mut_present )
500+
501+ R_with = ((df .PHENOTYPE == "R" ) & (df .has_mut )).sum ()
502+ S_with = ((df .PHENOTYPE == "S" ) & (df .has_mut )).sum ()
503+
504+ R_without = ((df .PHENOTYPE == "R" ) & (~ df .has_mut )).sum ()
505+ S_without = ((df .PHENOTYPE == "S" ) & (~ df .has_mut )).sum ()
506+
507+ return [[int (R_with ), int (S_with )], [int (R_without ), int (S_without )]]
508+
441509 @staticmethod
442510 def calc_odds_ratio (x : Contingency ) -> float :
443511 """
0 commit comments