:orphan: Source: bactoscoop/signalcorrelation.py ======================================= .. raw:: html
1# -*- coding: utf-8 -*-
2"""
3Created on Thu Aug 1 14:18:42 2024
4
5@author: Bart Steemans. Govers Lab.
6"""
7
8import pandas as pd
9import numpy as np
10from scipy import stats
11from skimage import filters
12from scipy.spatial.distance import pdist, squareform
13import logging
14
15bactoscoop_logger = logging.getLogger("logger")
16class SignalCorrelation:
17 def __init__(
18 self,
19 df,
20 channel1,
21 channel2,
22 feature,
23 method_name,
24 prepared_feature=None,
25 ):
26 self.df = df
27 self.channel1 = channel1
28 self.channel2 = channel2
29 self.feature = feature
30 self.method_name = method_name
31 self.prepared_feature = prepared_feature
32 self.method = self.get_method()
33 self.validate_inputs()
34
35 def get_method(self):
36 methods = {
37 "manders": self.manders_overlap_coefficient,
38 "pearson": self.pearson_correlation_coefficient,
39 "li_icq": self.li_icq,
40 "ratio": self.ratio,
41 "spearman": self.spearman_rank_correlation,
42 "kendall": self.kendall_tau,
43 "distance_corr": self.distance_correlation,
44 "covariance": self.covariance,
45 "n_cross_corr": self.normalized_cross_correlation,
46 "entropy_diff": self.entropy_difference,
47 "kurtosis_ratio": self.kurtosis_ratio,
48 "skewness_product": self.skewness_product,
49 "zero_crossings_diff": self.zero_crossings_difference,
50 "fft_peak_ratio": self.fft_peak_ratio,
51 "fft_energy_ratio": self.fft_energy_ratio,
52 "histogram_intersection": self.histogram_intersection,
53 "cosine_similarity": self.cosine_similarity,
54 }
55 if self.method_name in methods:
56 return methods[self.method_name]
57 else:
58 raise ValueError(f'Method "{self.method_name}" is not recognized')
59
60 def validate_inputs(self):
61 feature1 = f"{self.channel1}_{self.feature}"
62 feature2 = f"{self.channel2}_{self.feature}"
63 if feature1 not in self.df.columns or feature2 not in self.df.columns:
64 raise ValueError(
65 f"Features {feature1} and {feature2} must be in the DataFrame columns"
66 )
67
68 if not callable(self.method):
69 raise ValueError(f'The method "{self.method_name}" is not callable')
70
71 def calculate(self):
72 prepared_feature = self.prepared_feature
73 if prepared_feature is None:
74 prepared_feature = self.prepare_feature_pair(
75 self.df, self.channel1, self.channel2, self.feature
76 )
77
78 signals1 = prepared_feature["signals1"]
79 signals2 = prepared_feature["signals2"]
80 valid_mask = prepared_feature["valid_mask"]
81
82 results = [np.nan] * len(signals1)
83 for index, (signal1, signal2, is_valid) in enumerate(
84 zip(signals1, signals2, valid_mask)
85 ):
86 try:
87 if is_valid:
88 results[index] = self.method(signal1, signal2)
89 except ValueError as e:
90 bactoscoop_logger.debug(f"Encountered a ValueError: {e}")
91 continue
92
93 method_name = self.method.__name__
94 new_feature_name = (
95 f"{self.channel1}_{self.channel2}_{self.feature}_{method_name}"
96 )
97 self.df[new_feature_name] = results
98 return self.df
99
100 @classmethod
101 def prepare_feature_pair(cls, df, channel1, channel2, feature):
102 feature1 = f"{channel1}_{feature}"
103 feature2 = f"{channel2}_{feature}"
104 signals1 = df[feature1].to_numpy(copy=False)
105 signals2 = df[feature2].to_numpy(copy=False)
106 valid_mask = np.fromiter(
107 (
108 cls.is_valid_signal(signal1) and cls.is_valid_signal(signal2)
109 for signal1, signal2 in zip(signals1, signals2)
110 ),
111 dtype=bool,
112 count=len(signals1),
113 )
114 return {"signals1": signals1, "signals2": signals2, "valid_mask": valid_mask}
115
116 @staticmethod
117 def is_valid_signal(signal):
118 try:
119 signal_array = np.asarray(signal, dtype=float)
120 except (TypeError, ValueError):
121 return False
122 return bool(signal_array.size and np.isfinite(signal_array).all())
123
124 @staticmethod
125 def ratio(signal1, signal2):
126 if (
127 np.isscalar(signal1)
128 and np.isscalar(signal2)
129 and np.isfinite(signal1)
130 and np.isfinite(signal2)
131 and signal2 != 0
132 ):
133 return signal1 / signal2
134 return np.nan
135
136 @staticmethod
137 def manders_overlap_coefficient(signal1, signal2):
138 if isinstance(signal1, (list, np.ndarray)) and isinstance(
139 signal2, (list, np.ndarray)
140 ):
141 if len(signal1) > 1 and len(signal2) > 1:
142 signal1 = np.array(signal1)
143 signal2 = np.array(signal2)
144 return np.sum(signal1 * signal2) / np.sqrt(
145 np.sum(signal1**2) * np.sum(signal2**2)
146 )
147 return np.nan
148
149 @staticmethod
150 def pearson_correlation_coefficient(signal1, signal2):
151 if isinstance(signal1, (list, np.ndarray)) and isinstance(
152 signal2, (list, np.ndarray)
153 ):
154 if len(signal1) > 1 and len(signal2) > 1:
155 return stats.pearsonr(signal1, signal2)[0]
156 return np.nan
157
158 @staticmethod
159 def li_icq(signal1, signal2):
160 if isinstance(signal1, (list, np.ndarray)) and isinstance(
161 signal2, (list, np.ndarray)
162 ):
163 if len(signal1) > 1 and len(signal2) > 1:
164 signal1 = np.array(signal1)
165 signal2 = np.array(signal2)
166 mean1, mean2 = np.mean(signal1), np.mean(signal2)
167 product = ((signal1 - mean1) * (signal2 - mean2)) > 0
168 return (np.sum(product) / len(signal1) - 0.5) * 2
169 return np.nan
170
171 @staticmethod
172 def spearman_rank_correlation(signal1, signal2):
173 if isinstance(signal1, (list, np.ndarray)) and isinstance(
174 signal2, (list, np.ndarray)
175 ):
176 if len(signal1) > 1 and len(signal2) > 1:
177 return stats.spearmanr(signal1, signal2)[0]
178 return np.nan
179
180 @staticmethod
181 def kendall_tau(signal1, signal2):
182 if isinstance(signal1, (list, np.ndarray)) and isinstance(
183 signal2, (list, np.ndarray)
184 ):
185 if len(signal1) > 1 and len(signal2) > 1:
186 corr, _ = stats.kendalltau(signal1, signal2)
187 return corr
188 return np.nan
189
190 @staticmethod
191 def distance_correlation(signal1, signal2):
192 """Biased sample distance correlation; undefined profiles return NaN."""
193 if isinstance(signal1, (list, np.ndarray)) and isinstance(
194 signal2, (list, np.ndarray)
195 ):
196 if len(signal1) > 1 and len(signal2) > 1:
197 x = np.asarray(signal1, dtype=float)
198 y = np.asarray(signal2, dtype=float)
199 if (
200 x.ndim != 1
201 or y.ndim != 1
202 or len(x) != len(y)
203 or not np.isfinite(x).all()
204 or not np.isfinite(y).all()
205 ):
206 return np.nan
207
208 # Biased sample distance correlation uses separately centered
209 # pairwise distances for each signal.
210 distance_x = squareform(pdist(x[:, None]))
211 distance_y = squareform(pdist(y[:, None]))
212 centered_x = (
213 distance_x
214 - distance_x.mean(axis=0)
215 - distance_x.mean(axis=1)[:, None]
216 + distance_x.mean()
217 )
218 centered_y = (
219 distance_y
220 - distance_y.mean(axis=0)
221 - distance_y.mean(axis=1)[:, None]
222 + distance_y.mean()
223 )
224 variance_x = np.mean(centered_x**2)
225 variance_y = np.mean(centered_y**2)
226 if variance_x == 0 or variance_y == 0:
227 return np.nan
228 correlation_squared = np.mean(centered_x * centered_y) / np.sqrt(
229 variance_x * variance_y
230 )
231 return float(np.sqrt(np.clip(correlation_squared, 0, 1)))
232 return np.nan
233
234 @staticmethod
235 def covariance(signal1, signal2):
236 if isinstance(signal1, (list, np.ndarray)) and isinstance(
237 signal2, (list, np.ndarray)
238 ):
239 if len(signal1) > 1 and len(signal2) > 1:
240 return np.cov(signal1, signal2)[0, 1]
241 return np.nan
242
243 @staticmethod
244 def normalized_cross_correlation(signal1, signal2):
245 if isinstance(signal1, (list, np.ndarray)) and isinstance(
246 signal2, (list, np.ndarray)
247 ):
248 if len(signal1) > 1 and len(signal2) > 1:
249 return np.correlate(
250 signal1 - np.mean(signal1), signal2 - np.mean(signal2)
251 )[0] / (len(signal1) * np.std(signal1) * np.std(signal2))
252 return np.nan
253
254 @staticmethod
255 def entropy_difference(signal1, signal2):
256 if isinstance(signal1, (list, np.ndarray)) and isinstance(
257 signal2, (list, np.ndarray)
258 ):
259 if len(signal1) > 1 and len(signal2) > 1:
260 entropy1 = stats.entropy(signal1)
261 entropy2 = stats.entropy(signal2)
262 return np.abs(entropy1 - entropy2)
263 return np.nan
264
265 @staticmethod
266 def kurtosis_ratio(signal1, signal2):
267 if isinstance(signal1, (list, np.ndarray)) and isinstance(
268 signal2, (list, np.ndarray)
269 ):
270 if len(signal1) > 1 and len(signal2) > 1:
271 kurtosis1 = stats.kurtosis(signal1)
272 kurtosis2 = stats.kurtosis(signal2)
273 return np.abs(kurtosis1 / kurtosis2) if kurtosis2 != 0 else np.nan
274 return np.nan
275
276 @staticmethod
277 def skewness_product(signal1, signal2):
278 if isinstance(signal1, (list, np.ndarray)) and isinstance(
279 signal2, (list, np.ndarray)
280 ):
281 if len(signal1) > 1 and len(signal2) > 1:
282 skew1 = stats.skew(signal1)
283 skew2 = stats.skew(signal2)
284 return skew1 * skew2
285 return np.nan
286
287 @staticmethod
288 def zero_crossings_difference(signal1, signal2):
289 if isinstance(signal1, (list, np.ndarray)) and isinstance(
290 signal2, (list, np.ndarray)
291 ):
292 if len(signal1) > 1 and len(signal2) > 1:
293 zero_crossings1 = np.sum(np.diff(np.sign(signal1)) != 0)
294 zero_crossings2 = np.sum(np.diff(np.sign(signal2)) != 0)
295 return np.abs(zero_crossings1 - zero_crossings2)
296 return np.nan
297
298 @staticmethod
299 def fft_peak_ratio(signal1, signal2):
300 if isinstance(signal1, (list, np.ndarray)) and isinstance(
301 signal2, (list, np.ndarray)
302 ):
303 if len(signal1) > 1 and len(signal2) > 1:
304 fft1 = np.abs(np.fft.fft(signal1))
305 fft2 = np.abs(np.fft.fft(signal2))
306 peak1 = np.max(fft1[1:]) # Exclude DC component
307 peak2 = np.max(fft2[1:]) # Exclude DC component
308 return peak1 / peak2 if peak2 != 0 else np.nan
309 return np.nan
310
311 @staticmethod
312 def fft_energy_ratio(signal1, signal2):
313 if isinstance(signal1, (list, np.ndarray)) and isinstance(
314 signal2, (list, np.ndarray)
315 ):
316 if len(signal1) > 1 and len(signal2) > 1:
317 # Compute the FFT of the signals
318 fft1 = np.fft.fft(signal1)
319 fft2 = np.fft.fft(signal2)
320
321 # Compute the energy in each frequency bin
322 energy1 = np.sum(np.abs(fft1) ** 2)
323 energy2 = np.sum(np.abs(fft2) ** 2)
324
325 # Return the energy ratio
326 return energy1 / energy2 if energy2 != 0 else np.nan
327 return np.nan
328
329 @staticmethod
330 def histogram_intersection(signal1, signal2, bins=10):
331 """Intersect probability histograms using shared bins for both profiles."""
332 if isinstance(signal1, (list, np.ndarray)) and isinstance(
333 signal2, (list, np.ndarray)
334 ):
335 if len(signal1) > 1 and len(signal2) > 1:
336 x = np.asarray(signal1, dtype=float)
337 y = np.asarray(signal2, dtype=float)
338 if (
339 x.ndim != 1
340 or y.ndim != 1
341 or not np.isfinite(x).all()
342 or not np.isfinite(y).all()
343 ):
344 return np.nan
345
346 # Both histograms must describe the same intensity intervals.
347 # Normalize each by its own sample count so different profile
348 # lengths remain comparable and the score is symmetric.
349 edges = np.histogram_bin_edges(np.concatenate((x, y)), bins=bins)
350 hist1, _ = np.histogram(x, bins=edges)
351 hist2, _ = np.histogram(y, bins=edges)
352 count1 = hist1.sum()
353 count2 = hist2.sum()
354 if count1 == 0 or count2 == 0:
355 return np.nan
356 return float(np.minimum(hist1 / count1, hist2 / count2).sum())
357 return np.nan
358
359 @staticmethod
360 def cosine_similarity(signal1, signal2):
361 if isinstance(signal1, (list, np.ndarray)) and isinstance(
362 signal2, (list, np.ndarray)
363 ):
364 if len(signal1) > 1 and len(signal2) > 1:
365 return np.dot(signal1, signal2) / (
366 np.linalg.norm(signal1) * np.linalg.norm(signal2)
367 )
368 return np.nan