OSCR

Fluorescence spectroscopy and machine learning methods for detection of Alzheimer's disease from circulating white blood cells.

Code ↔ Paper

4 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 4 matches
  1. [1] § Methods › Machine learning methods and statistics for spectral image analysis ↔ stys_WTv5xFAD.py, lines 46–86 · score 0.82 · dimensional space, wavelet transform, stronger, weight, Euclidean, selection
  2. [2] § Methods › Machine learning methods and statistics for spectral image analysis ↔ stys_WTv5xFAD.py, lines 88–128 · score 0.75 · cross validated, decision boundary, Mac, rbf, minimizing, gamma
  3. [3] § Methods › Machine learning methods and statistics for spectral image analysis ↔ stys_WTv5xFAD.py, lines 1–44 · score 0.69 · surface scan, variance, UMAP1, MANOVA, SVM, subsets
  4. [4] § Results › Machine learning-based analysis of human PBMC immunoprecipitates ↔ stys_WTv5xFAD.py, lines 1027–1091 · score 0.59 · support vector regressor, cross validation, hyperparameters, splits, fitting, score

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 4,178 lines · 195 KB · CC-BY-4.0 · 4 matches

  1. """
  2. Python script used for wavelet decomposition>UMAP dim reduction>SVM classification_report
  3. Title: Fluorescence spectroscopy and machine learning methods for detection of Alzheimer's disease from circulating white blood cells
  4. Authors: Shigeki Tsutsui1, Anastasiia A. Stepanchuk, Julian P. Stys, Stefanie A.G. Black, George W. Templeton, Russell Greiner, Peter K. Stys
  5. Correspondence:
  6. Peter K. Stys, MD
  7. Hotchkiss Brain Institute, Department of Clinical Neurosciences, Cumming School of Medicine, University of Calgary,
  8. 3330 Hospital Drive, NW
  9. Calgary, AB Canada T2N 4N1
  10. Email: [email hidden]
  11. rev.2025-12-23
  12. """
  13. nShuffles = 10 # set to >0 to perform a final RAND sanity check (typically 5-10)
  14. dimReductionMethod = 'UMAP'
  15. nComponents = 2 # best total # of components for the dim reduction by SFS (must be >1 and <=maxComponents)
  16. maxComponents = 3 # number of components to be calculated in the reduction, from which the top nComponents are selected. Increasing maxComponents may yield better results but interpret with caution if the PCA % variance is low. If you want to default to the old behavior of selecting the FIRST nComponents set maxComponents=nComponents
  17. CWT_type = ['fbsp1-0.40-0.048'] # you can enter an explicit list of wavelet names here, otherwise 'deep' tells the script to scan a standard set for the best one
  18. CWT_N_scales = 32 # no. of scales = pixel height of scalogram (CAUTION: increasing this value excessively will place greater demand on memory, possibly crashing the system)
  19. scalogramCompressionPowerFactors_start = -0.2 # setting both scalogramCompressionPowerFactors_start & _end to negative values generates a series of only negative compression levels (sign of scalogramCompressionPowerFactors_nsteps is ignored)
  20. scalogramCompressionPowerFactors_end = -0.2
  21. ### SETTINGS ###
  22. # *** IMPORTANT: set CWT_N_scales=32 below if you change ExportKernelwiseReductions=True else you will almost certainly crash your system because of out-of-memory
  23. ExportKernelwiseReductions = False # set to False if you only want dim reductions on subject-wise average spectra, otherwise True will compute reductions on each kernel for surface scan import but will take a long time and is very memory and compute intensive. Note: not all wavelets will generate discernible clusters on the imported data surfaces, consider starting with sourceData=0 below.
  24. ShuffleMode = 0 # 0: only the classIDs are shuffled: some names will end up in the other class; 1: shuffle lambda rows: names retain their class IDs but inherit someone else's spectrum
  25. ClassPair = [0,1] # the 2 classIDs to compare (CSVs may have more than class 0 & 1; must have at least 2 elements)
  26. kNormMode = 1 # 0: don't normalize; 1: normalize each spectrum to its own peak
  27. StandardScaleReductions = True # if true, stdscales dim-reduced arrays after pca, umap, etc
  28. LambdaInputRange = [-1, 800] # restrict wavelength range [start nm,end nm] (spectral data outside this range will be deleted after load, but BEFORE normalization). Set start = -1 to include all wavelengths (NOTE: scalogramLambdaStartEndRange determines the actual range used for analyses; LambdaInputRange is mainly for display purposes)
  29. FirstLambdaCol = -1 # 0-based index of 1st wavelength column; typically 3; use -1 for auto-detect from column headers
  30. nLambdas = 32 # no. of wavelength columns in input csv; if FirstLambdaCol = -1 nLambdas will be auto-detected
  31. SubSampleSize = int(0) # 0 to use all rows in csvIN, otherwise set # of rows to use as a subset. Approx. 31GB of memory required per 1M input kernels
  32. ReplaceNameUnderscoresWithDashes = True # set to True so that names with underscores are not misinterpreted as replicates of same subject (_ char typically represents replicates of same subject)
  33. LimitMemoryUse = True # applies only when ExportKernelwiseReductions = True: set to False to bypass this script's attempt at limiting SubSampleSize to avoid OOM crashes (in which case watch memory pressure very carefully!)
  34. T = [1] # if csv has a 'T' column, retain only these T points (1-based). Set to -1 to retain all T-points. Can do eg T = [1,10] to retain an inclusive range of T points. Ignored if CSV has no T column.
  35. D = 0 # 0: use raw 0-order spectra; >0: higher order spectral derivatives
  36. # Savitzky–Golay filter params (set w_size = 0 for no filtering, applied to average spectra only, mainly applicable to Mk6 and Duetta spectra). You can examine what the filters will do using the CSV summary script first.
  37. SG_w_size = 0 # window size (must be odd); 0: no filtering; otherwise a value of 17 is a good choice for Duetta spectra; 31-41 for Mk5
  38. SG_polyorder = 3 # polynomial order
  39. componentList = [] # explicit 1-based list of components to use, overrides nComponents option. IMPORTANT: you may need to pass an explicit maxComponents (> greatest component in componentList) because UMAP (not sure about PCA) will generate different results for eg UMAP1 & 3 if maxComponents=3 vs 4 for instance. Settings maxComponents=0 will use the largest value in componentList as maxComponents.
  40. tie_breaker = 'dbi' # manova, silhouette (not as good?: permutation, eta_squared, mahalanobis, linsep, auc, dbi [Davies-Bouldin Index])
  41. # CWT OPTIONS (these only apply if sourceData = 1 below):
  42. CWT_scales_start = 0.3 # higher frequencies
  43. CWT_scales_end = 150 # lower frequencies
  44. CWT_scales_power = 2 # 1: linear, >1 more and more convex upward (see def custom_growth); controls the spread of scales/frequencies along the y-axis of the scalogram, higher powers give more grain at higher frequencies (smaller scales)
  45. CWT_generateStdWaveletList_n_linspace_points = 6 # only with CWT_type = 'deep' & 'fbsp-xxx'; see CustomWavelet.generateStdWaveletList (results can be very sensitive to small adjustments in wavelet type)
  46. # log sequence of power factors: smaller scalogramCompressionPowerFactors (more compression) will put more weight on lower amplitude components of your spectrum, which may or may not aid with group separation
  47. scalogramCompressionPowerFactors_nsteps = -7 # setting nsteps to a negative value will additionally append an equivalent set of -ve compression levels e.g. -4 will generate a series of 8 compression levels, 4 negative, 4 positive
  48. scalogramLambdaStartEndRange = [400, 800] # enter a restricted lambda range (in nm) for analysis/scalogram calculation. Not equivalent to LambdaInputRange if normalization is used.
  49. # scalogram_wsd_threshold =
  50. # None: omit WSD
  51. # 0.01-0.99: absolute normalized scalogram difference threshold: Lower threshold: More inclusive, keeps more regions of the scalogram; higher threshold (e.g., 0.6): more selective, focuses only on regions with the strongest differences
  52. # >= 1 interpreted as the percentage strongest total difference scalogram pixels to include
  53. # -1: auto-determine threshold
  54. scalogram_wsd_threshold = -40 # None: omit WSD feature engineering; -1 auto-determine threshold; 0.01-0.99 used as an absolute threshold value on the normalized scalogram difference; >= 1 interpreted as the percentage strongest total difference scalogram pixels to include; <-1 abs number of strongest pixels in scalogram diff to use (let's you set the abs # of extracted "features" from the scalograms) [see def CV_nested_cached]
  55. scalogram_wsd_window_percent = 20 # width/height of sliding window when computing mean scalogram diffs, as a % of nLambdas & CWT_N_scales
  56. scalogram_wsd_mask_power = 0 # scalogram mask power (0 for binary mask; > 0 graded mask values raised to this power e.g. 1 for untransformed graded mask; see class WaveletTransformer_cached_WSD)
  57. # UMAP options
  58. umap_repeats = 5 # UMAP appears to return highly variable solutions, many more repeats may be necessary to find even better solutions (NOTE: UMAP library has a memory leak so too many repeats will run out of mem)
  59. umap_n_neighbors = 30 # Higher values (e.g., 30–100) emphasize inter-class separation by capturing broader patterns in the data. Limited to nSamples.
  60. umap_min_dist = 1.0 # Higher values (0.5–1.0) spread clusters apart, enhancing inter-class separation. Use values > 1 with caution (check silhouette and DBI cluster metrics)
  61. umap_metric = 'cosine' # Defines the distance metric for high-dimensional space (e.g., euclidean, cosine). Non-Euclidean metrics (e.g., cosine for text) can better separate classes with non-linear relationships (https://umap-learn.readthedocs.io/en/latest/api.html)
  62. umap_init = 'spectral' # 'spectral','random','pca','tswspectral' (https://umap-learn.readthedocs.io/en/latest/api.html)
  63. # CNN_AE options (only when dimReductionMethod = 'CNN_AE')
  64. cnn_hidden_dims = [32] # determines no. of Conv2/3D layers (= no. of elements in the list). Add additional elements to this list to add additional Conv2/3D layers
  65. cnn_epochs=100
  66. cnn_batch_size=32
  67. cnn_use_attention = False # if True sets scalogram_wsd_threshold = -1 to omit WSD mechanism, can't use both (develop attention mechanism later, seems to run but needs optimization)
  68. # CNN_AE_PCA options (only when dimReductionMethod = 'CNN_AE_PCA')
  69. cnn_latent_dims = 64 # for CNN_AE_PCA only: latent dim of AE portion of the pipeline, which are then further reduced to nComponents,maxComponents by a final PCA
  70. # CNN1D_AE_PCA options (only when dimReductionMethod = 'CNN1D_AE_PCA' and sourceData = 0)
  71. cnn1d_pca_hidden_dims = [16]
  72. cnn1d_pca_latent_dims = 8 # latent dim of AE stage of the pipeline, which is then further reduced to nComponents,maxComponents by a final PCA. may need to increase this to 32 or 64 for Mk6/Duetta spectra
  73. cnn1d_pca_epochs = 500
  74. cnn1d_pca_batch_size=32
  75. cnn1d_pca_kernel_size=3
  76. cnn1d_pca_stride=2
  77. # LSTM_AE options
  78. lstm_bidirectional = True
  79. lstm_hidden_layers = 64
  80. lstm_epochs = 200
  81. lstm_batch_size = 16 # also for CNN_LSTM_AE
  82. lstm_learning_rate=1e-3 # or None
  83. lstm_patience=10
  84. lstm_min_delta=2e-4 # early stopping delta
  85. # CNN_LSTM_AE & CNN_LSTM_AE_PCA options
  86. cnn_lstm_conv_sizes = [32] # determines no. of Conv2 layers (= no. of elements in the list). Add additional elements to this list to add additional Conv2 layers
  87. cnn_lstm_hidden_size = 64 # number of units/neurons in the LSTM layer:
  88. cnn_lstm_latent_dims = 64 # for CNN_LSTM_AE_PCA only: latent dims of AE portion of the pipeline, which are then further reduced to nComponents,maxComponents by a final PCA reduction
  89. cnn_lstm_epochs = 200
  90. cnn_lstm_batch_size = 16
  91. # SVM/CV options
  92. import numpy as np
  93. svm_param_grid = {'C': np.logspace(-2, 2, 6), 'gamma': ['scale'], 'kernel': ['linear'], 'degree': [3]} # expanding C np.logspace, with larger range and more grain may find better solutions; gamma='scale' lets the SVM decide. Only a single entry for 'kernel' is allowed eg: linear, rbf (tends to heavily overfit for small sample sizes), sigmoid, poly. degree applies to 'poly' kernels only
  94. svm_MarginScorer_margin_weight = 0.01 # margin weight for SVM scoring function, see MarginScorer class. Higher values will sacrifice acc too much in favor of wider SVM margins. Small values will ensures that acc is first. Set to 0 to use 'accuracy' scoring only.
  95. svm_distance_mode = 1 # 0: distances are computed using svc.decision_function (this mode is forced for rbf kernels); 1: distances are computed manually using decision boundary (only applies to linear kernels)
  96. svm_iters = 5000 # max iters of base SVM in __compute_separation_sfs_xxx methods
  97. svm_random_state = 42
  98. k_q = 15 # threshold for LOOCV (N<k_q) vs RepeatedStratifiedKFold cross-validation (see CV_nested function)
  99. k_val_acc_tiebreaker_tolerance = 0.9 # see CV_nested method. Set to 1.0 to always use max val accuracy for tiebreaker pipeline set
  100. # auto-clustering options:
  101. nClusters = 2 # number of expected clusters in dataset (only applies to Clustering acc result)
  102. # plotting/output options:
  103. LabelDatapoints = False # label datapoints on the graphs with subject IDs
  104. subjectEmphasisList = [''] # enter a list of subject names to plot their spectra and markers in bold for identification e.g. subjectEmphasisList = ['HC-DN318','AD-BG0911']
  105. saveSVGgraphs = True # set to True to also export svg version of all graphs for import into graphics apps and editing for publication figs
  106. saveModel = True # set to True to also export svg version of all graphs for import into graphics apps and editing for publication figs
  107. # DEBUG
  108. kUseAUGforAllMethods = False # experimental: uses augmented datasets for all reducers, not just NN-based that require train-val splits. Currently only PCA is supported (in addition to NN-based reducers)
  109. kVerbose = False
  110. kMP = True # multiprocessing switch
  111. kGPU = True # use GPU if available (on Silicon Macs choose Stats Buddy>Hide Others and minimize all Stats Buddy windows to maximize GPU availability)
  112. #################
  113. # full path to input CSV (leave empty for SB to replace from GUI @ script launch; path cannot contain single quotes):
  114. csvIN='/Volumes/LabStuff/ITKs & images/test CSVs/AS_WT v 5XFAD_hi gain_PLAQUES OMITTED_K5.csv'
  115. # full path to input directory enclosing 1 or more CSVs (takes precedence over csvIN if both are defined). NOTE: Finder aliases will NOT be resolved:
  116. dirIN=''
  117. # full path to output directory (leave empty for SB to replace from GUI @ script launch; path cannot contain single quotes):
  118. dirOUT='/Users/pstys/Documents/dirOUT/'
  119. # full path to output file, if defined all strings sent to printSB will also be written to this file (useful for ARC jobs to write interim results to a file that don't seem to be printed in stdOut until the end of the job):
  120. # printSB_fOut=''
  121. import sys
  122. import time
  123. import math
  124. from scipy import stats
  125. import pandas as pd
  126. import os
  127. import sklearn
  128. import matplotlib.pyplot as plt
  129. from pathlib import Path
  130. import seaborn as sns
  131. import pywt
  132. from datetime import datetime
  133. import random
  134. import operator
  135. import multiprocessing
  136. from multiprocessing import Manager
  137. from scipy.signal import correlate
  138. import psutil
  139. from itertools import combinations
  140. from collections import defaultdict
  141. from sklearn import metrics, decomposition
  142. from sklearn.metrics import mean_squared_error, r2_score, make_scorer
  143. from sklearn.model_selection import permutation_test_score
  144. # from sklearn.base import BaseEstimator, RegressorMixin, TransformerMixin
  145. from sklearn.preprocessing import StandardScaler
  146. from sklearn.cluster import MiniBatchKMeans, KMeans, SpectralClustering
  147. from sklearn.metrics import classification_report, confusion_matrix, accuracy_score
  148. from sklearn.decomposition import NMF
  149. import umap
  150. import tensorflow as tf
  151. from tensorflow import keras
  152. from tensorflow.keras import layers, models, Model
  153. ########################### STANDARD_FUNCTION_BLOCK ###########################
  154. import math
  155. import warnings
  156. import os
  157. import numpy as np
  158. import pandas as pd
  159. from datetime import datetime
  160. from statsmodels.multivariate.manova import MANOVA
  161. import matplotlib.pyplot as plt
  162. from sklearn.preprocessing import StandardScaler
  163. from sklearn.model_selection import GridSearchCV, train_test_split, LeaveOneOut, cross_val_score, StratifiedKFold, RepeatedStratifiedKFold, cross_val_predict
  164. from sklearn.pipeline import Pipeline
  165. from sklearn.metrics import mean_squared_error, accuracy_score, silhouette_score, roc_auc_score, roc_curve, auc, davies_bouldin_score
  166. from sklearn.ensemble import RandomForestClassifier
  167. import random
  168. import re
  169. import tensorflow as tf
  170. from tensorflow.keras import layers, models
  171. from scipy.optimize import minimize
  172. import seaborn as sns
  173. warnings.filterwarnings("ignore") # suppress all warnings for this script run
  174. def formatPstring(P, includePrefix=True):
  175. prefix = ''
  176. if includePrefix: prefix = 'P='
  177. if (P is None) or (P > 1.0) or (P < 0) or math.isnan(P):
  178. Pstr = prefix + "???"
  179. elif P > 0.1:
  180. Pstr = prefix + "{:.2f}".format(P)
  181. elif P == 0.1:
  182. Pstr = prefix + "0.1"
  183. elif P > 0.01:
  184. Pstr = prefix + "{:.3f}".format(P)
  185. elif P == 0.01:
  186. Pstr = prefix + "0.01"
  187. elif P > 0.001:
  188. Pstr = prefix + "{:.4f}".format(P)
  189. elif P == 0.001:
  190. Pstr = prefix + "0.001"
  191. elif P > 0.0001:
  192. #Pstr = prefix + "{:.5f}".format(P)
  193. Pstr = prefix + "{:.1e}".format(P)
  194. elif P == 0.0001:
  195. #Pstr = prefix + "0.0001"
  196. Pstr = prefix + "1e-4"
  197. elif P > 1e-50:
  198. Pstr = prefix + "{:.1e}".format(P)
  199. else:
  200. Pstr = "P ≈ 0"
  201. # strip leading 0s in exponent
  202. Pstr = Pstr.replace("e+0", "e+")
  203. Pstr = Pstr.replace("e-0", "e-")
  204. return Pstr
  205. ### t-test
  206. # pass means_df that already contains the averaged components from all pixels/kernels, for each subject
  207. # see stats_tTest_raw for passing raw kernel-wise data
  208. def stats_tTest(means_df,defaultLabel='PC1',twoTail=True, classList=[0,1]):
  209. import math
  210. from scipy import stats
  211. from sklearn import metrics
  212. try:
  213. means2class_df = means_df[means_df['classID'].isin(classList)] # drop all rows that are not in classList
  214. means2class_df.reset_index(inplace=True, drop=True) # don't forget this if you want to re-index rows from 0
  215. a = means2class_df.loc[means2class_df['classID'] == classList[0]][defaultLabel] # extract all lda_means where classID=0
  216. b = means2class_df.loc[means2class_df['classID'] == classList[1]][defaultLabel] # extract all lda_means where classID=1
  217. MD = abs(round(np.average(b) - np.average(a),3))
  218. if twoTail:
  219. tStat, pValue = stats.ttest_ind(a, b, equal_var = True, alternative='two-sided') #run independent 2 sample T-Test
  220. else:
  221. tStat, pValue = stats.ttest_ind(a, b, equal_var = True, alternative='less') #a < b
  222. P = pValue
  223. Pstr = formatPstring(P)
  224. # AUC https://scikit-learn.org/stable/modules/generated/sklearn.metrics.auc.html
  225. labels_0 = np.zeros(len(a)).astype(int)
  226. labels_1 = np.ones(len(b)).astype(int)
  227. labels = np.concatenate((labels_0,labels_1))
  228. aANDb = np.concatenate((a,b))
  229. y = labels
  230. pred = aANDb
  231. fpr, tpr, thresholds = metrics.roc_curve(y, pred, pos_label=1)
  232. AUC = metrics.auc(fpr, tpr)
  233. if AUC == 1.0:
  234. AUCstr = 'AUC=1.0'
  235. else:
  236. AUCstr = 'AUC={:.2f}'.format(AUC)
  237. CM = max(0,AUC-0.5)*(-math.log(max(P,1e-32))) # composite metric: AUC and P only (limit to 1e-32 to avoid math error)
  238. #CM = round(AUC*MD*(-math.log(P))) # composite metric
  239. #CM = round(AUC*MD*(CA*100-50)*(-math.log(P))) # composite metric, this should match Orange
  240. if P < 0:
  241. CM = -1
  242. CMstr = 'CM:ERR (P<0)'
  243. elif P == 0:
  244. CM = 1e32
  245. CMstr = 'CM≈ ∞ (P=0)'
  246. else:
  247. CM = max(0,AUC-0.5) * (-math.log(P))
  248. CMstr = 'CM={:.1f}'.format(CM)
  249. except: # catch malformed inputs eg. a single class, too few Ns, etc
  250. P = -1
  251. Pstr = '*** ERROR ***'
  252. CM = -1
  253. CMstr = '*** ERROR ***'
  254. MD = -1
  255. AUC = -1
  256. AUCstr = '*** ERROR ***'
  257. return P, Pstr, CM, CMstr, MD, AUC, AUCstr
  258. ### t-test on raw kernel-wise (not averaged) input data
  259. # pass raw_df that containes kernel-wise (not averaged)data, for each subject
  260. # nComponents: # PCs to calculate, must be <= # features, >= than the default colLabel requested for the t-test
  261. def stats_tTest_raw(raw_df, defaultLabel='PC1', twoTail=True, classList=[0,1]):
  262. means2class_df = raw_df[raw_df['classID'].isin(classList)] # drop all rows that are not in classList
  263. means2class_df.reset_index(inplace=True, drop=True) # don't forget this if you want to re-index rows from 0
  264. names_unique = means2class_df['name'].unique()
  265. N_names_unique = names_unique.shape[0] # how many subjects?
  266. classIDarr = np.zeros(N_names_unique).astype(int) # classIds will match names_unique
  267. # prepare a df to accept the subject-wise PCA means
  268. PCAmeansDF = pd.DataFrame(0.0,index=range(N_names_unique),columns=[defaultLabel]) # 0.0 forces floats
  269. for nameCtr in range(N_names_unique):
  270. n = names_unique[nameCtr]
  271. subarr = means2class_df[means2class_df['name'] == n] # extract all rows from raw_df for this name
  272. subarr.reset_index(inplace = True, drop = True)
  273. classID = subarr['classID'].iloc[0]
  274. classIDarr[nameCtr] = classID
  275. mean_pca_n = subarr[defaultLabel].mean()
  276. PCAmeansDF[defaultLabel].at[nameCtr] = mean_pca_n
  277. # assemble results into a df
  278. names_df = pd.DataFrame(data=names_unique,columns=['name'])
  279. classID_df = pd.DataFrame(data=classIDarr,columns=['classID'])
  280. PCAout_df = pd.concat([names_df,classID_df,PCAmeansDF], axis = 1)
  281. # now do the t-test on the single requested PC
  282. return stats_tTest(PCAout_df,defaultLabel=defaultLabel,twoTail=twoTail, classList=classList)
  283. def stats_tTest_np(group1,group2, twoTail=True):
  284. # group1&2 are 1D np vectors
  285. # twoTail=False: group2 > group1
  286. import math
  287. from scipy import stats
  288. try:
  289. if twoTail:
  290. tStat, pValue = stats.ttest_ind(group1.reshape(-1), group2.reshape(-1), equal_var = True, alternative='two-sided') # run independent 2 sample T-Test; .reshape(-1) because we may pass a (N,1) 2D array
  291. else:
  292. tStat, pValue = stats.ttest_ind(group1.reshape(-1), group2.reshape(-1), equal_var = True, alternative='less') # group1 < group2
  293. P = pValue
  294. Pstr = formatPstring(P)
  295. except:
  296. P = -1
  297. Pstr = '*** ERROR ***'
  298. finally:
  299. return P, Pstr
  300. ### MANOVAs
  301. def stats_MANOVA(nComponents,pca_df,defaultLabel='PC',PClist=[], epsilon=1e-7): # epsilon=1e-7: anything smaller and the MANOVA P again returns 1.0
  302. # compute SUBJECT-WISE PCA component means
  303. # pass nComponents=0 and a non-empty PClist if you want to select specific PCs for the MANOVA
  304. # defaultLabel (col names) cannot begin with a number
  305. # see stats_MANOVA_np for adding jitter
  306. try:
  307. pca2class_df = pca_df.drop(pca_df[pca_df['classID'] > 1].index) # drop all rows that are not classID 0 or 1
  308. pca2class_df.reset_index(inplace=True, drop=True) # don't forget this if you want to re-index rows from 0
  309. # names_unique = pca_df['name'].unique()
  310. names_unique = pca2class_df['name'].unique()
  311. N_names_unique = names_unique.shape[0] # how many subjects?
  312. classIDarr = np.zeros(N_names_unique).astype(int) # classIds will match names_unique
  313. # prepare a df to accept the subject-wise PCA means
  314. if nComponents == 0: # specific PCs from a list
  315. PCAmeansDF = pd.DataFrame(0.0,index=range(N_names_unique),columns=[defaultLabel+'%i' % PClist[i] for i in range(len(PClist))]) # 0.0 forces floats
  316. else:
  317. PCAmeansDF = pd.DataFrame(0.0,index=range(N_names_unique),columns=[defaultLabel+'%i' % i for i in range(1,nComponents+1)]) # 0.0 forces floats
  318. for nameCtr in range(N_names_unique):
  319. n = names_unique[nameCtr]
  320. subarr = pca_df[pca_df['name'] == n] # extract all rows from pca_df for this name
  321. subarr.reset_index(inplace = True, drop = True)
  322. classID = subarr['classID'].iloc[0]
  323. classIDarr[nameCtr] = classID
  324. if nComponents == 0: # specific PCs from a list
  325. for componentCtr in PClist:
  326. pca_label = defaultLabel+str(componentCtr)
  327. # printSB(pca_label)
  328. mean_pca_n = subarr[pca_label].mean()
  329. if epsilon>0: mean_pca_n += np.random.uniform(-epsilon, epsilon)*mean_pca_n
  330. PCAmeansDF[pca_label].at[nameCtr] = mean_pca_n
  331. else: # 1st nComponents PCs
  332. for componentCtr in range(nComponents):
  333. i=componentCtr+1
  334. pca_label = defaultLabel+'%i' % i
  335. mean_pca_n = subarr[pca_label].mean()
  336. if epsilon>0: mean_pca_n += np.random.uniform(-epsilon, epsilon)*mean_pca_n
  337. PCAmeansDF[pca_label].at[nameCtr] = mean_pca_n
  338. # assemble results into a df
  339. names_df = pd.DataFrame(data=names_unique,columns=['name'])
  340. classID_df = pd.DataFrame(data=classIDarr,columns=['classID'])
  341. PCAout_df = pd.concat([names_df,classID_df,PCAmeansDF], axis = 1)
  342. # compute MANOVA on PCA means
  343. if (np.count_nonzero(classIDarr==0) < 3) or (np.count_nonzero(classIDarr==1) < 3): # must have a minimum in each class?
  344. raise Exception
  345. independent_variable = 'classID'
  346. formulaStr = ''
  347. if nComponents == 0: # specific PCs from a list
  348. for componentCtr in PClist:
  349. pca_label = defaultLabel+str(componentCtr)
  350. formulaStr = formulaStr + pca_label
  351. if componentCtr != PClist[-1]:
  352. formulaStr = formulaStr + ' + '
  353. else:
  354. for componentCtr in range(nComponents):
  355. i=componentCtr+1
  356. # pca_label = 'PC%i' % i
  357. pca_label = defaultLabel+'%i' % i
  358. formulaStr = formulaStr + pca_label
  359. if componentCtr < nComponents-1:
  360. formulaStr = formulaStr + ' + '
  361. formulaStr = formulaStr + ' ~ ' + independent_variable
  362. # printSB(formulaStr)
  363. fit = MANOVA.from_formula(formulaStr, data=PCAout_df)
  364. # extract the P value
  365. test_id = 1 # index of testID e.g. Pillai's trace = 1 from above table
  366. P = fit.mv_test().results[independent_variable]['stat'].values[test_id, 4] # 4 is index of the P value column
  367. Pstr = formatPstring(P)
  368. except: # catch malformed inputs eg. a single class, or N<3, etc
  369. P = -1
  370. Pstr = '*** ERROR ***'
  371. PCAout_df = None
  372. return P, Pstr, PCAout_df
  373. def stats_MANOVA_np(group1, group2, epsilon=1e-7): # epsilon=1e-7: anything smaller and the MANOVA P again returns 1.0
  374. # group1&2 are 2D np arrays (Nsamples,NdependentVariables)
  375. try:
  376. if (len(group1) < 3) or (len(group2) < 3): return -1,'*** ERROR ***'
  377. if epsilon > 0: # because MANOVA will incorrectly return P=1 when all values in a column are equal, apply a tiny, random jitter
  378. random_values = np.random.uniform(-epsilon, epsilon, size=group1.shape) * group1 # * group1 ensures that the jitter is always 1e-7x smaller than the magnitude of each element, ensuring that we don't appreciably alter the statistics
  379. # Add the random values to group_0_data
  380. group1_jitter = group1.copy() + random_values
  381. random_values = np.random.uniform(-epsilon, epsilon, size=group2.shape) * group2
  382. group2_jitter = group2.copy() + random_values
  383. else:
  384. group1_jitter = group1
  385. group2_jitter = group2
  386. # Combine the arrays and create a grouping factor
  387. data = np.concatenate((group1_jitter, group2_jitter), axis=0)
  388. groups = np.repeat(['Group 1', 'Group 2'], repeats=[len(group1_jitter), len(group2_jitter)])
  389. # Create a DataFrame for MANOVA (dynamically generate column names)
  390. num_columns = data.shape[1] # Get the number of columns
  391. column_names = [f'Var{i+1}' for i in range(num_columns)]
  392. df = pd.DataFrame(data, columns=column_names)
  393. df['Group'] = groups
  394. # Construct the formula string dynamically
  395. formula_str = ' + '.join(column_names) + ' ~ Group'
  396. # Fit the MANOVA model
  397. manova_model = MANOVA.from_formula(formula_str, data=df)
  398. # Print the MANOVA results
  399. #printSB(manova_model.mv_test())
  400. # Extract and print the P-values
  401. results_summary = manova_model.mv_test()
  402. pillai_trace_pvalue = results_summary.results['Group']['stat'].loc['Pillai\'s trace']['Pr > F']
  403. wilks_lambda_pvalue = results_summary.results['Group']['stat'].loc['Wilks\' lambda']['Pr > F']
  404. hotelling_trace_pvalue = results_summary.results['Group']['stat'].loc['Hotelling-Lawley trace']['Pr > F']
  405. roy_largest_root_pvalue = results_summary.results['Group']['stat'].loc['Roy\'s greatest root']['Pr > F']
  406. P = pillai_trace_pvalue
  407. Pstr = formatPstring(P)
  408. except:
  409. P = -1
  410. Pstr = '*** ERROR ***'
  411. finally:
  412. return P, Pstr
  413. def stats_MANOVA_Xy_np(X, y):
  414. # pass both classes in a single 2D array X, y is used to separate by class
  415. unique_classes = np.unique(y)
  416. if len(unique_classes)>2: raise ValueError(f'stats_MANOVA_Xy_np: y must have only 2 unique classes (unique_classes={unique_classes})')
  417. X0 = X[y == unique_classes[0]]
  418. X1 = X[y == unique_classes[1]]
  419. return stats_MANOVA_np(X0, X1)
  420. ### MANOVA on univariate Duetta spectr
  421. def stats_MANOVA_Duetta(nComponents, pca_df, classList=[0,1], defaultLabel='PC'): # compute MANOVA on PCA means
  422. try:
  423. pca2class_df = pca_df[pca_df['classID'].isin(classList)] # drop all rows that are not in classList
  424. pca2class_df.reset_index(inplace=True, drop=True) # don't forget this if you want to re-index rows from 0
  425. names_unique = pca2class_df['name'].unique()
  426. N_names_unique = names_unique.shape[0] # how many subjects?
  427. classIDarr = pca2class_df['classID']
  428. if (np.count_nonzero(classIDarr==classList[0]) < 3) or (np.count_nonzero(classIDarr==classList[1]) < 3): # must have a minimum in each class?
  429. raise Exception
  430. independent_variable = 'classID'
  431. formulaStr = ''
  432. for componentCtr in range(nComponents):
  433. i=componentCtr+1
  434. pca_label = defaultLabel+'%i' % i
  435. formulaStr = formulaStr + pca_label
  436. if componentCtr < nComponents-1:
  437. formulaStr = formulaStr + ' + '
  438. formulaStr = formulaStr + ' ~ ' + independent_variable
  439. # printSB(formulaStr)
  440. fit = MANOVA.from_formula(formulaStr, data= pca2class_df)
  441. # extract the P value
  442. test_id = 1 # index of testID e.g. Pillai's trace = 1 from above table
  443. P = fit.mv_test().results[independent_variable]['stat'].values[test_id, 4] # 4 is index of the P value column
  444. Pstr = formatPstring(P)
  445. except: # catch malformed inputs eg. a single class, or N<3, etc
  446. P = -1
  447. Pstr = '*** ERROR ***'
  448. return P, Pstr
  449. # calculates a composite metric. Higher values of p_value_logbase emphasize AUC over P value. p_value_logbase = 0 to use AUC only
  450. def calcCM(p_value, AUC_value, accuracy=-1, p_value_logbase=10, multiplier=1):
  451. # pass AUC_value=-1 or accuracy=-1 to omit from CM calc
  452. # new multiplier can be passed to multiply the computed CM eg. inter-group delta
  453. if accuracy < 0:
  454. acc = 1.0 # omit from calc
  455. else:
  456. acc = accuracy
  457. if AUC_value < 0:
  458. AUC = 1.0 # omit from calc
  459. else:
  460. AUC = AUC_value
  461. if math.isnan(p_value):
  462. CM = -1
  463. CMstr = 'CM: ERR (P<0)'
  464. elif p_value_logbase <= 0: # AUC-only
  465. CM = AUC
  466. CM *= multiplier
  467. CMstr = 'CM={:.2f}'.format(CM)
  468. elif p_value < 0:
  469. CM = -1
  470. CMstr = 'CM: ERR (P<0)'
  471. elif p_value == 0:
  472. CM = 100 # arbitrary ceiling
  473. CMstr = 'CM≈ ∞ (P=0)'
  474. else:
  475. CM = AUC * (-math.log(p_value,p_value_logbase)) * acc # 2024-03-24: now log base 10 to further de-emphasize effect of p_value
  476. CM *= multiplier # typically absMeanGrpDiff/2 to capture spread between the group means after regression
  477. CMstr = 'CM={:.1f}'.format(CM)
  478. return CM, CMstr
  479. ### conditions raw Duetta/Mk5 spectra
  480. def conditionSpectra(spectralData, # input df, 1st 3 cols typically name, classID, T
  481. normMode, # 0: don't normalize; 1: normalize each spectrum to its own peak; 2: normalize each T series to T0 spectrum
  482. firstSpectralColumnIx, # 0-based index of 1st wavelength column
  483. baselineStart_nm, # baseline extends from baselineStart_nm baselineEnd_nm; enter 0 to begin at start of spectrum
  484. baselineEnd_nm, # enter 0 for no baseline subtraction
  485. spectrumStart_nm, # nominal start of spectrum with leading baseline omitted; enter 0 to include entire spectrum including baseline
  486. spectrumEnd_nm, # nominal end of spectrum; enter 0 to include entire spectrum to end
  487. Tmin=1, # a subset of time series is extracted: this is the first t-point (1-based index)
  488. Tmax=-1, # a subset of time series is extracted: this is the last t-point (1-based index); -1 for all
  489. SG_w_size = 0, # Sav-Gol filter window, 0 for no filtering
  490. SG_polyorder = 3, # Sav-Gol polyorder
  491. decimate_q = 1, # decimate factor if > 1 (https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.decimate.html)
  492. replaceNameUnderscoresWithDashes = False, # _ represent replicates of same subject, set to true to treat these as different subjects
  493. variablesColID='T', # label of true time dimension ie complete spectra acquired at nT time points during the PC
  494. graphOutPDF = '' # file path to save conditioned spectral overlay
  495. ):
  496. from scipy import signal
  497. # printSB('Conditioning input spectra...')
  498. # extract the wavelength vector
  499. cols = spectralData.columns
  500. nm = cols[firstSpectralColumnIx:].astype('float') # wavelengths
  501. if Tmax == -1: Tmax = spectralData[variablesColID].max()+1 # 1-based
  502. spectralDataSubset = spectralData.loc[spectralData[variablesColID] < Tmax] # extract subset of T-points
  503. spectralDataSubset = spectralDataSubset.loc[spectralDataSubset[variablesColID] >= Tmin-1]
  504. spectralDataSubset.reset_index(inplace=True,drop=True)
  505. ### extract the row metas
  506. IDarr = spectralDataSubset.loc[:,('name','classID',variablesColID)] # variablesColID = 'T'
  507. if replaceNameUnderscoresWithDashes:
  508. IDarr['name'] = IDarr['name'].str.replace('_','-')
  509. spectralDataSubset = spectralDataSubset.iloc[:,firstSpectralColumnIx:] # we will operate on just the spectral data for simplicity
  510. ### Sav-Gol filter for normalizing (to avoid spurious peaks)
  511. polyorder = SG_polyorder # polynomial order [3]
  512. w_size = SG_w_size
  513. if SG_w_size == 0: # if not filtering of raw spectra use this default filter for norm peak detection only
  514. w_size = int(nm.shape[0] / 20) # window size [33]
  515. w_size = max(SG_polyorder +1,w_size) # polyorder must be less than window_length
  516. ### baseline subtract
  517. spectralDataSubset_bsub = spectralDataSubset.copy()
  518. if nm[0] < baselineEnd_nm:
  519. baselineIndexesGT = np.where(nm>=baselineStart_nm)[0]
  520. baselineIndexesLT = np.where(nm<=baselineEnd_nm)[0]
  521. baselineIndexes = np.intersect1d(baselineIndexesGT, baselineIndexesLT)
  522. firstBaselineIx = baselineIndexes[0] # 0-based ix of first bin of baseline
  523. lastBaselineIx = baselineIndexes[-1] # 0-based ix of last bin of baseline
  524. # subtract baseline
  525. for j in range(spectralDataSubset.shape[0]):
  526. # baseline = spectralDataSubset.iloc[j,firstBaselineIx:lastBaselineIx].mean(axis=0)
  527. baseline = spectralDataSubset.iloc[j,firstBaselineIx:lastBaselineIx].mean(axis=0)
  528. spectralDataSubset_bsub.iloc[j,:] -= baseline
  529. ### normalize
  530. spectralDataSubset_bsub_norm = spectralDataSubset_bsub
  531. # printSB(spectralDataSubset_bsub_norm.max().max())
  532. if normMode == 0: # don't normalize
  533. pass # do nothing more
  534. elif normMode == 1: # normalize each spectrum to its own peak
  535. spectralDataSubset_bsub_filt = spectralDataSubset_bsub.copy()
  536. for rowCtr in range(spectralDataSubset_bsub.shape[0]):
  537. y = spectralDataSubset_bsub.iloc[rowCtr,:].to_numpy()
  538. y_filt = signal.savgol_filter(y, w_size, polyorder, mode="nearest")
  539. spectralDataSubset_bsub_filt.iloc[rowCtr,:] = y_filt
  540. # normalize each spectrum to its own (filtered) peak value
  541. for j in range(spectralDataSubset_bsub.shape[0]):
  542. maxRowVal = spectralDataSubset_bsub_filt.iloc[j,:].max(axis=0)
  543. spectralDataSubset_bsub_norm.iloc[j,:] /= maxRowVal
  544. else: # normalize each spectral series to Tmin
  545. # first extract all T0 spectra
  546. # spectralDataSubset_bsub_T0 = spectralDataSubset_bsub.loc[spectralDataSubset_bsub[variablesColID]==0] # all T0 spectra
  547. spectralDataSubset_bsub_Tmin = spectralDataSubset_bsub[IDarr[variablesColID] == Tmin-1] # extract all rows from spectralDataSubset_bsub where corresponding rows in IDarr['T'] == Tmin-1
  548. spectralDataSubset_bsub_Tmin.reset_index(inplace=True,drop=True)
  549. IDarr_Tmin = IDarr.loc[IDarr[variablesColID] == Tmin-1] # all Tmin meta
  550. IDarr_Tmin.reset_index(inplace=True,drop=True)
  551. # S-G filter T0 spectra to get true noise-free maxval for norm
  552. spectralDataSubset_bsub_Tmin_filt = spectralDataSubset_bsub_Tmin.copy()
  553. for rowCtr in range(spectralDataSubset_bsub_Tmin.shape[0]):
  554. y = spectralDataSubset_bsub_Tmin.iloc[rowCtr,:].to_numpy()
  555. y_filt = signal.savgol_filter(y, w_size, polyorder, mode="nearest")
  556. spectralDataSubset_bsub_Tmin_filt.iloc[rowCtr,:] = y_filt
  557. for nameCtr in range(IDarr_Tmin.shape[0]):
  558. name = IDarr_Tmin.iloc[nameCtr,IDarr_Tmin.columns.get_loc('name')]
  559. maxVal_Tmin = spectralDataSubset_bsub_Tmin_filt.iloc[nameCtr,:].max(axis=0)
  560. #printSB(name + ' max: ' + str(maxVal_T0))
  561. for rowCtr in range(spectralDataSubset_bsub_norm.shape[0]):
  562. if IDarr.iloc[rowCtr,IDarr.columns.get_loc('name')] == name: # does the row name match name?
  563. spectralDataSubset_bsub_norm.iloc[rowCtr,:] /= maxVal_Tmin
  564. #printSB(spectralDataSubset_bsub_norm.max().max())
  565. ### S-G filter raw spectral data
  566. #printSB(firstSpectralColumnIx)
  567. if SG_w_size > 0:
  568. for rowCtr in range(spectralDataSubset_bsub_norm.shape[0]):
  569. y = spectralDataSubset_bsub_norm.iloc[rowCtr,:].to_numpy()
  570. y_filt = signal.savgol_filter(y, SG_w_size, polyorder, mode="nearest")
  571. spectralDataSubset_bsub_norm.iloc[rowCtr,:] = y_filt
  572. ### downsample
  573. if decimate_q > 1:
  574. y = spectralDataSubset_bsub_norm.iloc[0,:].to_numpy()
  575. y_filt = signal.decimate(y, q=decimate_q) # dummy (ftype = "fir" doesn't work!)
  576. #printSB(y_filt.shape)
  577. # Initialize a 2D Pandas DataFrame with all zeroes
  578. spectralDataSubset_bsub_norm_dec = pd.DataFrame(0, index=np.arange(spectralDataSubset_bsub_norm.shape[0]), columns=np.arange(y_filt.shape[0]))
  579. for rowCtr in range(spectralDataSubset_bsub_norm.shape[0]):
  580. y = spectralDataSubset_bsub_norm.iloc[rowCtr,:].to_numpy()
  581. y_filt = signal.decimate(y, q=decimate_q, n=SG_w_size) # ftype = "fir" doesn't work!
  582. spectralDataSubset_bsub_norm_dec.iloc[rowCtr,:] = y_filt
  583. # nm = signal.decimate(nm, q=decimate_q, n=0) # must decimate the nm vector too! This imparts a significant phase shift int he nm values
  584. nm_dec = nm[::decimate_q]
  585. spectralDataSubset_bsub_norm = spectralDataSubset_bsub_norm_dec
  586. else:
  587. nm_dec = nm
  588. spectralDataSubset_bsub_norm.columns = spectralDataSubset_bsub_norm.columns.astype(str) # make sure col names are type str
  589. # rename columns with nm
  590. # nm_str = nm_dec.astype(str)
  591. # spectralDataSubset_bsub_norm.columns.values[firstSpectralColumnIx:] = nm_str ### THIS SHOULD WORK BUT CRASHES THE KERNEL!!!
  592. for j in range(nm_dec.shape[0]):
  593. spectralDataSubset_bsub_norm.columns.values[j] = "{:.1f}".format(nm_dec[j])
  594. ### extract required wavelength subrange
  595. if spectrumStart_nm == 0: spectrumStart_nm = nm_dec[0] # from start
  596. if spectrumEnd_nm == 0: spectrumEnd_nm = nm_dec[-1] # to end
  597. specIndexesGT = np.where(nm_dec>=spectrumStart_nm)[0]
  598. specIndexesLT = np.where(nm_dec<=spectrumEnd_nm)[0]
  599. specIndexes = np.intersect1d(specIndexesGT, specIndexesLT)
  600. first_nmIx = specIndexes[0]
  601. last_nmIx = specIndexes[-1]
  602. spectraExtracted = spectralDataSubset_bsub_norm.iloc[:,first_nmIx:last_nmIx+1]
  603. spectraExtracted.reset_index(inplace=True,drop=True)
  604. #printSB(spectraExtracted.max().max())
  605. ### reassemble into a final output df
  606. conditioned_df = pd.DataFrame(data=IDarr)
  607. conditioned_df = pd.concat([conditioned_df,spectraExtracted], axis = 1)
  608. #printSB(conditioned_df.iloc[:,firstSpectralColumnIx:].max().max())
  609. # nm_conditioned = spectraExtracted.columns.astype('float') # wavelengths
  610. nm_conditioned = nm_dec[first_nmIx:last_nmIx+1] # wavelength subrange
  611. if graphOutPDF != '': # plot conditioned UNfiltered spectra
  612. plt.style.use('bmh')
  613. plt.figure(figsize=(6, 4))
  614. spectraExtracted_T0 = conditioned_df.loc[conditioned_df[variablesColID] == 0] # extract T0 spectra
  615. spectraExtracted_T0.reset_index(inplace=True,drop=True)
  616. # there must be a vectorized way to do the below:
  617. labels = spectraExtracted_T0['classID']
  618. colors = ['green','red']
  619. colorArr = []
  620. for i in range(labels.shape[0]):
  621. colorArr.append(colors[labels[i]])
  622. x = nm_conditioned
  623. # we plot the T0 spectra only
  624. for i in range(spectraExtracted_T0.shape[0]):
  625. y = spectraExtracted_T0.iloc[i,firstSpectralColumnIx:].transpose()
  626. plt.plot(x, y, color=colorArr[i],linewidth=1.0)
  627. #plt.show()
  628. plt.savefig(graphOutPDF)
  629. printSB('Conditioned T0 spectral graph saved to: ' + graphOutPDF)
  630. plt.close()
  631. conditioned_df.reset_index(inplace=True, drop=True)
  632. return conditioned_df, nm_conditioned
  633. ### extract root name e.g. abc_def_1 returns abc_def; abc-def-1 returns abc-def-1,
  634. def extractRootname(name, separator = '_'):
  635. rpart = name.rpartition(separator)
  636. if rpart[0] == '':
  637. # return rpart[2],'' # separator does not exist: complete name is in [2]
  638. return rpart[2] # separator does not exist: complete name is in [2]
  639. else:
  640. # return rpart[0],rpart[2] # [0] contains everything ahead of last separator, [2] contains suffix after last seprator
  641. return rpart[0] # [0] contains everything ahead of last separator
  642. ### extract root names from a column named 'name' in df
  643. ### use this to get subject rootnames from an augmented dataset (must call BEFORE underscore replacement ie: if kReplaceNameUnderscoresWithDashes: imageData['name'] = imageData['name'].str.replace('_','-') )
  644. def extractRootnames(df, separator = '_AUG_'): # df must have a 'name' and 'classID' column
  645. rootNames = []
  646. rootClassIDs = []
  647. uniqueNames = df['name'].unique()
  648. for n in uniqueNames:
  649. rootNames.append(extractRootname(n, separator))
  650. classID_value = df.loc[df['name'] == n, 'classID'].values[0]
  651. rootClassIDs.append(classID_value)
  652. rootNames_df = pd.DataFrame(list(zip(rootNames, rootClassIDs)), columns=['name','classID'])
  653. rootNames_df.drop_duplicates(subset='name', inplace=True) # extract unique names in df
  654. rootNames_df.sort_values(by=['classID', 'name'], inplace=True)
  655. rootNames_df.reset_index(drop=True, inplace=True)
  656. rootNames = list(set(rootNames)) # extract unique names in list
  657. return rootNames, rootNames_df # return as a list and a df
  658. ### summarizes the extracted root subjects and replicate instances for a set of Duetta/Mk5 input spectra
  659. def summarizeSubjects(input_df): # pass a pandas df with a column 'name'
  660. instance_names_all = input_df['name']
  661. instance_names_all
  662. instance_classIDs_all = input_df['classID']
  663. instance_classIDs_all
  664. instance_names_unique = instance_names_all.unique()
  665. instance_names_unique
  666. instance_classIDs_unique = []
  667. for unique_name in instance_names_unique:
  668. for k in range(instance_names_all.shape[0]):
  669. if instance_names_all[k] == unique_name:
  670. instance_classIDs_unique.append(instance_classIDs_all[k])
  671. break # continue with next unique_name
  672. nClass0 = instance_classIDs_unique.count(0)
  673. nClass1 = instance_classIDs_unique.count(1)
  674. root_names = []
  675. root_classIDs = []
  676. for j in range(instance_names_unique.shape[0]):
  677. root_names.append(extractRootname(instance_names_unique[j]))
  678. root_classIDs.append(instance_classIDs_unique[j])
  679. root_names_unique = np.unique(root_names)
  680. root_classIDs_unique = []
  681. for unique_rootname in root_names_unique:
  682. for k in range(len(root_names)):
  683. if root_names[k] == unique_rootname:
  684. root_classIDs_unique.append(root_classIDs[k])
  685. break # continue with next unique_rootname
  686. nClass0_root = root_classIDs_unique.count(0)
  687. nClass1_root = root_classIDs_unique.count(1)
  688. maxNameLen = len(max(instance_names_unique, key=len)) # used for padding below
  689. result = 'All instances'.ljust(max(3+maxNameLen+2,len('All instances')+1)) + 'classID (' + str(nClass0) + '+' + str(nClass1) + '):\n'
  690. for j in range(instance_names_unique.shape[0]):
  691. result = result + ' ' + instance_names_unique[j].ljust(max(maxNameLen+2,len('All instances')+1-3)) + str(instance_classIDs_unique[j]) + '\n'
  692. maxRootnameLen = len(max(root_names_unique, key=len)) # used for padding below
  693. result = result + 'Root names'.ljust(max(3+maxRootnameLen+2,len('Root names')+1)) + 'classID (' + str(nClass0_root) + '+' + str(nClass1_root) + '):\n'
  694. for j in range(root_names_unique.shape[0]):
  695. result = result + ' ' + root_names_unique[j].ljust(max(maxRootnameLen+2, len('Root names')+1-3)) + str(root_classIDs_unique[j]) + '\n'
  696. result = result + '\n'
  697. return result
  698. ### strips the Orange tokens from col headers
  699. def fixOrangeHeaders_OLD(df):
  700. cols = df.columns
  701. colsAdj = []
  702. for headerItem in cols:
  703. # printSB(headerItem)
  704. if '#' in headerItem:
  705. colsAdj.append(headerItem.rsplit('#')[1])
  706. else:
  707. colsAdj.append(headerItem)
  708. df.columns = colsAdj
  709. return df
  710. # strip Orange tokens in-place, returns the adjusted col names, 0-based ix of 1st lambda col, number of lambda cols
  711. def fixOrangeHeaders(df):
  712. """
  713. Regular Expression Pattern: The r'^.*#' regular expression does the following:
  714. ^: Matches the start of the string (column name).
  715. .*: Matches any character (.) zero or more times (*).
  716. #: Matches the literal '#' character.
  717. Together, this pattern matches everything from the beginning of the column name up to and including the first '#' character it finds.
  718. https://g.co/gemini/share/bc7dc4af6baf
  719. """
  720. def starts_with_L_and_numeric(s):
  721. # Regular expression pattern to match 'L' followed by a valid numeric value
  722. pattern = r'^L\d+(\.\d+)?$'
  723. # Use re.match to check if the string matches the pattern
  724. return bool(re.match(pattern, s))
  725. columns = df.columns.str.replace(r'^.*#', '', regex=True).tolist()
  726. df.columns = columns
  727. # now auto-detect start and end of lambda cols
  728. first_numeric = -1
  729. last_numeric = -1
  730. for i, col in enumerate(columns):
  731. try:
  732. # Try converting the string to a float
  733. float(col)
  734. if first_numeric == -1: first_numeric = i
  735. last_numeric = i
  736. except ValueError:
  737. pass # Ignore non-numeric strings
  738. # lambda cols might be 'L400', 'L410', etc
  739. if first_numeric == -1: # we never found a numeric column with orange tokens above
  740. for i, col in enumerate(columns):
  741. if starts_with_L_and_numeric(col):
  742. if first_numeric == -1: first_numeric = i
  743. last_numeric = i
  744. columns[i] = columns[i].lstrip('L')
  745. if first_numeric > 0: df.columns = columns # update the column names with the stripped Ls if we found any
  746. firstLambdaCol = first_numeric
  747. nLambdas = last_numeric - first_numeric + 1
  748. return columns, firstLambdaCol, nLambdas # columns is a list of string
  749. def get_child_files(directory, fIn='', extension='csv'): # optional fIn returned if directory=''; typically pass dirIN, csvIN
  750. # returns a list of fullpath(s)
  751. # new version: fIn can contain a tab-delim list of full paths to accommodate the new csvInList() array in SB
  752. file_paths = []
  753. # non-nil enclosing directory takes precedence
  754. if directory!='': # extract all csv files from directory
  755. for root, dirs, files in os.walk(directory):
  756. for file in files:
  757. if file.endswith('.' + extension): file_paths.append(os.path.join(root, file))
  758. elif "\t" in fIn: # if fIn contains a tab it's a list of files
  759. file_paths = fIn.split("\t")
  760. else: # append only a single fIn to list
  761. file_paths.append(fIn)
  762. return file_paths
  763. ### load a flow cytometry 3.0 fcs file
  764. # call with path to fcs3.0 file, returns a dataframe with raw data as floats
  765. # Time column is removed
  766. # min/maxLimits needed to strip occasional rogue values
  767. def load_fcs(fcsPath, minRogueLimit=1, maxRogueLimit=1e6):
  768. f = open(fcsPath, "rb")
  769. # The format name is encoded in 6 letters
  770. # An ASCII letter is coded with one octet
  771. #file_format = "".join([f.read(1) for __ in range(6)])
  772. file_format = f.read(6).decode('ascii')
  773. #sys.stdout.write("Format: %s\n" % file_format)
  774. #printSB("Format: %s\n" % file_format)
  775. # The format descriptions reserves 4 octets that we skip
  776. skip = f.read(4)
  777. # 8 octet chunks encode the start and end positions
  778. # of different parts of the data
  779. text_start = int(f.read(8).decode('ascii').strip())
  780. text_end = int(f.read(8).decode('ascii').strip())
  781. data_start = int(f.read(8).decode('ascii').strip())
  782. data_end = int(f.read(8).decode('ascii').strip())
  783. analysis_start = int(f.read(8).decode('ascii').strip())
  784. analysis_end = int(f.read(8).decode('ascii').strip())
  785. ####################################################
  786. # Here starts the parsing of the "TEXT" portion #
  787. # which describes how the data proper is organized #
  788. ####################################################
  789. f.seek(text_start)
  790. # The first character in the primary TEXT segment is the ASCII delimiter character.
  791. sep = f.read(1).decode('ascii')
  792. text_segment = f.read(text_end - text_start).decode('ascii')
  793. fields = text_segment.split(sep)
  794. info = {} # dictionary
  795. i = 0
  796. while i < len(fields) - 1:
  797. key = fields[i]
  798. i += 1
  799. val = fields[i]
  800. i += 1
  801. # Keywords are case insensitive, they may be written in a file in lower case, upper case, or a
  802. # mixture of the two. However, an FCS file reader must ignore keyword case. A keyword value may
  803. # be in lower case, upper case or a mixture of the two. Keyword values are case sensitive.
  804. info[key.upper()] = val
  805. # extract parameter names
  806. parameters = []
  807. # indices of the parameters
  808. p_indices = range(1, int(info["$PAR"]) + 1)
  809. for i in p_indices:
  810. p_name = info["$P%dN" % i]
  811. parameters.append(p_name)
  812. if info["$BYTEORD"] == "4,3,2,1":
  813. endianness = ">"
  814. else:
  815. endianness = "<"
  816. assert info["$BYTEORD"] == "1,2,3,4"
  817. # Type of data:
  818. if info["$DATATYPE"] == "F":
  819. nParams = int(info["$PAR"])
  820. nEvents = int(info["$TOT"]) # no of floats should be nParams * nEvents
  821. f.seek(data_start)
  822. flatFloats = np.fromfile(f, dtype=np.float32, count=nParams*nEvents).byteswap().newbyteorder(sys.byteorder)
  823. # Reshape the array into a 2D array with nEvents rows and 8 columns
  824. reshaped_flatFloats = flatFloats.reshape(nEvents, nParams)
  825. # Convert the reshaped array into a DataFrame
  826. df = pd.DataFrame(data=reshaped_flatFloats,columns=parameters)
  827. if 'Time' in df.columns: df.drop('Time', axis=1, inplace=True) # Remove the 'Time' column, messes up limits below
  828. df = df[~(df > maxRogueLimit).any(axis=1)]
  829. df = df[~(df < minRogueLimit).any(axis=1)]
  830. df.reset_index(inplace=True, drop=True)
  831. else:
  832. df = pd.DataFrame() # empty placeholder
  833. printSB('*** Only float data are supported ***')
  834. f.close()
  835. return df
  836. def cleanAUGfname(fBaseNoExt): # removes the unique '_NNNN' suffix appended to AUGnX fnames
  837. if (fBaseNoExt[-6:-4] == 'X_') and (fBaseNoExt[-10:-7] == 'AUG'):
  838. return fBaseNoExt[0:-5]
  839. else:
  840. return fBaseNoExt
  841. ### computes support vector classification or regression on X & Y
  842. ### needs global vars: maxIter_svm, param_grid for grid search
  843. def SVCR(kSVregression, X, Y, kGridSearch=True, SVhyperparameters = [10, 1, 'linear', 0.0001], n_splits=10, n_repeats=5): # SVhyperparameters: C, gamma, kernel (other kernel options: 'rbf'), ignored when kGridSearch=True
  844. from sklearn.svm import SVC,SVR
  845. from sklearn.model_selection import GridSearchCV, RepeatedKFold
  846. from sklearn.preprocessing import StandardScaler
  847. scaler = StandardScaler()
  848. C_svm = SVhyperparameters[0]
  849. gamma_svm = SVhyperparameters[1]
  850. kernel_svm = SVhyperparameters[2]
  851. tol_svm = SVhyperparameters[3]
  852. rkf = RepeatedKFold(n_splits=min(n_splits,X.shape[0]), n_repeats=n_repeats)
  853. printSB()
  854. if kSVregression: # SVR
  855. if kGridSearch == False:
  856. svm_model = SVR(kernel=kernel_svm,verbose=3,C=C_svm, gamma=gamma_svm, max_iter=maxIter_svm, tol=tol_svm) # https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVR.html
  857. svm_model.fit(X, Y)
  858. #predictions
  859. y_pred = svm_model.predict(X) # make predictions on the input set to check accurcy of linear separability
  860. else: # grid search
  861. svm_model = SVR() # Create the SVR regressor
  862. grid_search = GridSearchCV(estimator=svm_model, param_grid=param_grid, cv=rkf)
  863. grid_search.fit(X, Y)
  864. printSB("Grid-search best SVR parameters (NOTE: rbf kernel tends to overfit, check with RAND):", grid_search.best_params_)
  865. y_pred = grid_search.predict(X) # make predictions on the input set to check accurcy of linear separability
  866. decision_function_df = pd.DataFrame(data=y_pred,columns=['score'])
  867. accPercent = -1 # undefined for SVR
  868. else: # SVC
  869. if kGridSearch == False:
  870. # svm_model = SVC(kernel= kernel_svm,verbose=3,C=C_svm, gamma=gamma_svm, max_iter=maxIter_svm, tol=tol_svm, random_state=42) # https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html?highlight=svc#sklearn.svm.SVC
  871. svm_model = SVC(kernel= kernel_svm,verbose=3,C=C_svm, gamma=gamma_svm, max_iter=maxIter_svm) # https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html?highlight=svc#sklearn.svm.SVC
  872. svm_model.fit(X, Y)
  873. #predictions
  874. y_pred = svm_model.predict(X) # make predictions on the input set to check accurcy of linear separability
  875. decision_function = svm_model.decision_function(X)
  876. if kStandardScaleResult:
  877. decision_function = scaler.fit_transform(decision_function.reshape(-1, 1)) # often decision_function contains tiny values so rescaling standardizes when averaging with different hyperparameters
  878. else: # grid search
  879. svm_model = SVC() # Create the SVM classifier
  880. grid_search = GridSearchCV(estimator=svm_model, param_grid=param_grid, cv=rkf)
  881. grid_search.fit(X, Y)
  882. printSB("Grid-search best SVC parameters (NOTE: rbf kernel tends to overfit, check with RAND):", grid_search.best_params_)
  883. y_pred = grid_search.predict(X) # make predictions on the input set to check accurcy of linear separability
  884. decision_function = grid_search.decision_function(X)
  885. if kStandardScaleResult:
  886. decision_function = scaler.fit_transform(decision_function.reshape(-1, 1)) # often decision_function contains tiny values so rescaling standardizes when averaging with different hyperparameters
  887. decision_function_df = pd.DataFrame(data=decision_function,columns=['score'])
  888. accPercent = 100*accuracy_score(Y, y_pred)
  889. printSB()
  890. return decision_function_df, accPercent
  891. ### computes support vector regression with cross-validation on X & Y
  892. ### needs global vars: param_grid for grid search
  893. def SVR_CV(X, Y, n_splits=10, n_repeats=5):
  894. from sklearn.svm import SVR
  895. from sklearn.model_selection import train_test_split, GridSearchCV, cross_val_score, RepeatedKFold
  896. ### Sandardization of data ###
  897. from sklearn.preprocessing import StandardScaler
  898. PredictorScaler=StandardScaler()
  899. TargetVarScaler=StandardScaler()
  900. # Storing the fit object for later reference
  901. PredictorScalerFit=PredictorScaler.fit(X)
  902. TargetVarScalerFit=TargetVarScaler.fit(Y.values.reshape(-1,1))
  903. # Generating the standardized values of X and y
  904. Xstd=PredictorScalerFit.transform(X)
  905. Ystd=TargetVarScalerFit.transform(Y.values.reshape(-1,1))
  906. # Ystd=TargetVarScalerFit.transform(Y.values)
  907. # Ystd = np.ravel(Ystd)
  908. rkf = RepeatedKFold(n_splits=n_splits, n_repeats=n_repeats)
  909. svm_model = SVR() # Create the SVR regressor
  910. grid_search = GridSearchCV(estimator=svm_model, param_grid=param_grid, cv=rkf, n_jobs=-1)
  911. grid_search.fit(Xstd, Ystd)
  912. printSB("Grid-search best SVR parameters (NOTE: rbf kernel tends to overfit, check with RAND):", grid_search.best_params_)
  913. y_pred = grid_search.predict(Xstd) # make predictions on the input set
  914. # Scaling the predicted Price data back to original price scale
  915. y_pred=TargetVarScalerFit.inverse_transform(y_pred.reshape(-1,1))
  916. decision_function_df = pd.DataFrame(data=y_pred,columns=['score'])
  917. return decision_function_df
  918. ### computes support vector classification on X & Y, selects the best hyperparams based on CM (composite metric)
  919. ### pass subject-wise mean PCs in PCmeans_df: 1st col is name, 2nd col is classID, rest are PCs
  920. ### needs global vars: maxIter_svm, param_grid for grid search, kStandardScaleResult
  921. # see also SVC_CM2
  922. # 2024-06-15: pass an explicit list of 0-based featureColumnIndexes to use for SVC instead of trying to determine these from defaultLabel
  923. def SVC_CM1(PCmeans_df, param_grid, n_splits=10, n_repeats=5, suppressWarnings=False, defaultLabel='PC', featureColumnIndexes=[], p_value_logbase=10):
  924. from sklearn.svm import SVC
  925. from sklearn.model_selection import GridSearchCV, RepeatedKFold
  926. from sklearn import metrics
  927. from sklearn.preprocessing import StandardScaler
  928. C_arr = param_grid.get('C')
  929. gamma_arr = param_grid.get('gamma')
  930. kernel = param_grid.get('kernel')
  931. tol = param_grid.get('tol')
  932. rkf = RepeatedKFold(n_splits=min(n_splits,PCmeans_df.shape[0]), n_repeats=n_repeats)
  933. scaler = StandardScaler()
  934. CMbest = -1e32
  935. dict_best = {'CM':-1} # in case no solution/exception
  936. #X = PCmeans_df.iloc[:,2:]
  937. if len(featureColumnIndexes) == 0:
  938. first_PC_index = next((i for i, col in enumerate(PCmeans_df.columns) if col.startswith(defaultLabel)), -1) # find index of 1st PCx or UMAPx etc column
  939. if first_PC_index == -1:
  940. printSB('SVC_CM1: PCx columns could not be found (fatal error)')
  941. return dict_best, pd.DataFrame()
  942. X = PCmeans_df.iloc[:,first_PC_index:]
  943. else: # user passed explicit 0-based feature column indexes
  944. try:
  945. X = PCmeans_df.iloc[:,featureColumnIndexes]
  946. #printSB(X)
  947. except:
  948. printSB('Invalid featureColumnIndexes')
  949. return dict_best, pd.DataFrame()
  950. #Y = PCmeans_df.iloc[:,1]
  951. Y = PCmeans_df['classID']
  952. svm_model = SVC() # Create the SVM classifier
  953. for C in C_arr:
  954. for gamma in gamma_arr:
  955. d = {'C': [C], 'gamma': [gamma], 'kernel': kernel, 'tol': tol} # create a 4-element dictionary for this C,gamma pair
  956. #printSB(d)
  957. grid_search = GridSearchCV(estimator=svm_model, param_grid=d, cv=rkf)
  958. grid_search.fit(X, Y)
  959. y_pred = grid_search.predict(X) # make predictions on the input set to check accurcy of linear separability
  960. decision_function = grid_search.decision_function(X)
  961. if kStandardScaleResult:
  962. decision_function = scaler.fit_transform(decision_function.reshape(-1, 1)) # often decision_function contains tiny values so rescaling standardizes when averaging with different hyperparameters
  963. decision_function_df = pd.DataFrame(data=decision_function,columns=['score'])
  964. # now compute t-test for dec fn, AUC -> CM
  965. a = decision_function_df.loc[PCmeans_df['classID'] == 0]['score']
  966. b = decision_function_df.loc[PCmeans_df['classID'] == 1]['score']
  967. _, pValue = stats.ttest_ind(a, b, equal_var = True, alternative='less') #run independent 2 sample T-Test
  968. y_classification = np.where(decision_function_df['score'] > 0, 1, 0) # so convert it to a predicted 0 or 1 class, depending on <0 vs >0
  969. # AUC
  970. predicted_probabilities = np.where(decision_function_df['score'] > 0, 1, 0) # so convert it to a predicted 0 or 1 class, depending on <0 vs >0
  971. actual_labels = PCmeans_df['classID']
  972. AUC = metrics.roc_auc_score(actual_labels, predicted_probabilities)
  973. if AUC == 1.0:
  974. AUCstr = 'AUC = 1.0'
  975. else:
  976. AUCstr = 'AUC = {:.2f}'.format(AUC)
  977. # accuracy
  978. accuracy = metrics.accuracy_score(actual_labels, y_classification)
  979. if accuracy == 1.0:
  980. accuracyStr = 'accuracy=1.0'
  981. else:
  982. accuracyStr = 'accuracy={:.2f}'.format(accuracy)
  983. # f1 score
  984. f1 = metrics.f1_score(actual_labels, y_classification)
  985. # composite metric
  986. CM, CMstr = calcCM(pValue, AUC, accuracy, p_value_logbase=p_value_logbase) # Higher values of p_value_logbase emphasize AUC over P value; p_value_logbase=0 uses AUC only
  987. #printSB('nuSVC_CM: ',CMstr)
  988. if CM > CMbest:
  989. CMbest = CM
  990. if p_value_logbase <= 0: CM, CMstr = calcCM(pValue, AUC, accuracy, p_value_logbase=10) # for scoring/optimization purposes we use the AUC-only CM, but for reporting we recalc it the regular way so it's comparable to other runs
  991. #dict_best = {'nu':nu, 'pValue':pValue, 'Pstr':formatPstring(pValue), 'AUC':AUC, 'AUCstr':AUCstr, 'accuracy':accuracy, 'accuracyStr':accuracyStr, 'f1':f1, 'CM':CM, 'CMstr':CMstr, 'decision_function_df':decision_function_df, 'y_pred':y_classification}
  992. dict_best = {'C':C, 'gamma':gamma, 'kernel':kernel, 'tol':tol, 'pValue':pValue, 'Pstr':formatPstring(pValue), 'AUC':AUC, 'AUCstr':AUCstr, 'accuracy':accuracy, 'accuracyStr':accuracyStr, 'f1':f1, 'CM':CM, 'CMstr':CMstr, 'decision_function_df':decision_function_df, 'y_pred':y_classification}
  993. #printSB('SVC_CM1:dict_best',dict_best)
  994. return dict_best # return the dict with best results (see above for keys). get elements of dict like this: dict_best['pValue']
  995. ### computes nu-support vector classification on X & Y, selects the best hyperparams based on CM (composite metric)
  996. ### pass subject-wise mean PCs in PCmeans_df: 1st col is name, 2nd col is classID, rest are PCs
  997. ### needs global vars: maxIter_svm, param_grid for grid search?, kStandardScaleResult
  998. # 2024-04-23: CM now includes accuracy as well as log(P) and AUC
  999. # 2024-06-15: pass an explicit list of 0-based featureColumnIndexes to use for SVC instead of trying to determine these from defaultLabel
  1000. def SVC_CM2(PCmeans_df, param_grid, n_splits=10, n_repeats=5, suppressWarnings=False, defaultLabel='PC', featureColumnIndexes=[], p_value_logbase=10):
  1001. # PCmeans_df can be UMAP means, etc
  1002. from sklearn.svm import SVC
  1003. from sklearn.model_selection import GridSearchCV, RepeatedKFold
  1004. from sklearn import metrics
  1005. from sklearn.preprocessing import StandardScaler
  1006. rkf = RepeatedKFold(n_splits=min(n_splits,PCmeans_df.shape[0]), n_repeats=n_repeats)
  1007. scaler = StandardScaler()
  1008. dict_best = {'CM':-1} # in case no solution/exception
  1009. if len(featureColumnIndexes) == 0:
  1010. first_PC_index = next((i for i, col in enumerate(PCmeans_df.columns) if col.startswith(defaultLabel)), -1) # find index of 1st PCx or UMAPx etc column
  1011. if first_PC_index == -1:
  1012. printSB('SVC_CM2: PCx columns could not be found (fatal error)')
  1013. return dict_best, pd.DataFrame()
  1014. X = PCmeans_df.iloc[:,first_PC_index:]
  1015. else: # user passed explicit 0-based feature column indexes
  1016. try:
  1017. X = PCmeans_df.iloc[:,featureColumnIndexes]
  1018. #printSB(X)
  1019. except:
  1020. printSB('Invalid featureColumnIndexes')
  1021. return dict_best, pd.DataFrame()
  1022. Y = PCmeans_df['classID']
  1023. svm_model = SVC()
  1024. try: # some values of nu are illegal and will throw an exception: catch it and return what we have
  1025. grid_search = GridSearchCV(estimator=svm_model, param_grid=param_grid, cv=rkf)
  1026. grid_search.fit(X, Y)
  1027. y_pred = grid_search.predict(X) # make predictions on the input set to check accurcy of linear separability
  1028. decision_function = grid_search.decision_function(X)
  1029. if kStandardScaleResult:
  1030. decision_function = scaler.fit_transform(decision_function.reshape(-1, 1)) # often decision_function contains tiny values so rescaling standardizes when averaging with different hyperparameters
  1031. decision_function_df = pd.DataFrame(data=decision_function,columns=['score'])
  1032. #printSB('nuSVC_CM: about to stats.ttest_ind')
  1033. # now compute t-test for dec fn, AUC -> CM
  1034. a = decision_function_df.loc[PCmeans_df['classID'] == 0]['score']
  1035. b = decision_function_df.loc[PCmeans_df['classID'] == 1]['score']
  1036. _, pValue = stats.ttest_ind(a, b, equal_var = True, alternative='less') #run independent 2 sample T-Test
  1037. #printSB('nuSVC_CM: about to metrics.roc_auc_score')
  1038. # AUC
  1039. #predicted_probabilities = decision_function_df['score'] THIS IS WRONG: because dec_func varies from <0 to >0 (either side of the hyperplane) it is not a 0-1 probability which is what metrics.roc_auc_score expects
  1040. y_classification = np.where(decision_function_df['score'] > 0, 1, 0) # so convert it to a predicted 0 or 1 class, depending on <0 vs >0
  1041. actual_labels = PCmeans_df['classID']
  1042. AUC = metrics.roc_auc_score(actual_labels, y_classification)
  1043. if AUC == 1.0:
  1044. AUCstr = 'AUC = 1.0'
  1045. else:
  1046. AUCstr = 'AUC = {:.2f}'.format(AUC)
  1047. #printSB('nuSVC_CM: about to metrics.accuracy_score')
  1048. # accuracy
  1049. accuracy = metrics.accuracy_score(actual_labels, y_classification)
  1050. if accuracy == 1.0:
  1051. accuracyStr = 'accuracy=1.0'
  1052. else:
  1053. accuracyStr = 'accuracy={:.2f}'.format(accuracy)
  1054. # f1 score
  1055. f1 = metrics.f1_score(actual_labels, y_classification)
  1056. # composite metric
  1057. CM, CMstr = calcCM(pValue, AUC, accuracy, p_value_logbase=p_value_logbase) # Higher values of p_value_logbase emphasize AUC over P value
  1058. dict_best = {'C':grid_search.best_params_['C'], 'gamma':grid_search.best_params_['gamma'], 'kernel':grid_search.best_params_['kernel'], 'tol':grid_search.best_params_['tol'], 'pValue':pValue, 'Pstr':formatPstring(pValue), 'AUC':AUC, 'AUCstr':AUCstr, 'accuracy':accuracy, 'accuracyStr':accuracyStr, 'f1':f1, 'CM':CM, 'CMstr':CMstr, 'decision_function_df':decision_function_df, 'y_pred':y_classification}
  1059. except:
  1060. if suppressWarnings == False: printSB('SVC_CM2 exception')
  1061. # sort by CM
  1062. #sorted_dList = sorted(dict_out_list, key=lambda x: x['CM'], reverse=True) # sort by CM: sorted_dList[0] is the best dict
  1063. return dict_best # return the dict with best results (see above for keys). get elements of dict like this: d_best.get('decision_function_df')
  1064. ### computes nu-support vector classification on X & Y, selects the best hyperparams based on CM (composite metric)
  1065. ### pass subject-wise mean PCs in PCmeans_df: 1st col is name, 2nd col is classID, rest are PCs
  1066. ### needs global vars: maxIter_svm, param_grid for grid search, kStandardScaleResult
  1067. # 2024-04-23: CM now includes accuracy as well as log(P) and AUC
  1068. # 2024-05-11: now must pass explict param_grid argument, else risk of altering the global and having obscure bad results
  1069. # 2024-06-15: pass an explicit list of 0-based featureColumnIndexes to use for SVC instead of trying to determine these from defaultLabel
  1070. def nuSVC_CM(PCmeans_df, param_grid, n_splits=10, n_repeats=5, suppressWarnings=False, defaultLabel='PC', featureColumnIndexes=[], p_value_logbase=10):
  1071. # PCmeans_df can be UMAP means, etc
  1072. from sklearn.svm import NuSVC
  1073. from sklearn.model_selection import GridSearchCV, RepeatedKFold
  1074. from sklearn import metrics
  1075. from sklearn.preprocessing import StandardScaler
  1076. nu_arr = param_grid.get('nu')
  1077. kernel = param_grid.get('kernel')
  1078. tol = param_grid.get('tol')
  1079. rkf = RepeatedKFold(n_splits=min(n_splits,PCmeans_df.shape[0]), n_repeats=n_repeats)
  1080. scaler = StandardScaler()
  1081. CMbest = -1e32
  1082. dict_best = {'CM':-1} # in case no solution/exception
  1083. if len(featureColumnIndexes) == 0:
  1084. first_PC_index = next((i for i, col in enumerate(PCmeans_df.columns) if col.startswith(defaultLabel)), -1) # find index of 1st PCx or UMAPx etc column
  1085. if first_PC_index == -1:
  1086. printSB('nuSVC_CM: PCx columns could not be found (fatal error)')
  1087. return dict_best, pd.DataFrame()
  1088. X = PCmeans_df.iloc[:,first_PC_index:]
  1089. else: # user passed explicit 0-based feature column indexes
  1090. try:
  1091. X = PCmeans_df.iloc[:,featureColumnIndexes]
  1092. #printSB(X)
  1093. except:
  1094. printSB('Invalid featureColumnIndexes')
  1095. return dict_best, pd.DataFrame()
  1096. Y = PCmeans_df['classID']
  1097. # svm_model = SVC() # Create the SVM classifier
  1098. # svm_model = NuSVC(tol=0.0001, random_state=42)
  1099. svm_model = NuSVC(tol=tol)
  1100. try: # some values of nu are illegal and will throw an exception: catch it and return what we have
  1101. for nu in nu_arr:
  1102. d = {'nu': [nu], 'kernel': kernel, 'tol': [tol]} # create a 2-element dictionary for this C,gamma pair
  1103. #printSB('nuSVC_CM: ',d)
  1104. grid_search = GridSearchCV(estimator=svm_model, param_grid=d, cv=rkf)
  1105. grid_search.fit(X, Y)
  1106. y_pred = grid_search.predict(X) # make predictions on the input set to check accurcy of linear separability
  1107. decision_function = grid_search.decision_function(X)
  1108. if kStandardScaleResult:
  1109. decision_function = scaler.fit_transform(decision_function.reshape(-1, 1)) # often decision_function contains tiny values so rescaling standardizes when averaging with different hyperparameters
  1110. decision_function_df = pd.DataFrame(data=decision_function,columns=['score'])
  1111. #accPercent = 100*accuracy_score(Y, y_pred)
  1112. a = decision_function_df.loc[PCmeans_df['classID'] == 0]['score']
  1113. b = decision_function_df.loc[PCmeans_df['classID'] == 1]['score']
  1114. _, pValue = stats.ttest_ind(a, b, equal_var = True, alternative='less') #run independent 2 sample T-Test
  1115. y_classification = np.where(decision_function_df['score'] > 0, 1, 0) # so convert it to a predicted 0 or 1 class, depending on <0 vs >0
  1116. actual_labels = PCmeans_df['classID']
  1117. AUC = metrics.roc_auc_score(actual_labels, y_classification)
  1118. if AUC == 1.0:
  1119. AUCstr = 'AUC = 1.0'
  1120. else:
  1121. AUCstr = 'AUC = {:.2f}'.format(AUC)
  1122. #printSB('nuSVC_CM: about to metrics.accuracy_score')
  1123. # accuracy
  1124. accuracy = metrics.accuracy_score(actual_labels, y_classification)
  1125. if accuracy == 1.0:
  1126. accuracyStr = 'accuracy=1.0'
  1127. else:
  1128. accuracyStr = 'accuracy={:.2f}'.format(accuracy)
  1129. # f1 score
  1130. f1 = metrics.f1_score(actual_labels, y_classification)
  1131. # composite metric
  1132. if p_value_logbase <= 0: # by AUC first (but not only, so we also find best P value)
  1133. CM = AUC + (-math.log(pValue,1e300)) / 1000 # de-emphasize log(P) more by /1000 because e.g. log1e300(1e-20) = -0.06666 may still have an effect on "AUC-only" CM (AUC+logP, not AUC*logP)
  1134. CM /= 100 # reduce further because above log may still return values large enough to affect the AUC-only CM for very small Ps (but we still want a little effect of P on CM so we can find the best AUC first, then the best P)
  1135. CMstr = str(CM) # placeholder, not useful
  1136. else:
  1137. CM, CMstr = calcCM(pValue, AUC, accuracy, p_value_logbase=p_value_logbase) # Higher values of p_value_logbase emphasize AUC over P value; p_value_logbase=0 uses AUC only
  1138. #printSB('nuSVC_CM: ',CMstr)
  1139. if CM > CMbest:
  1140. CMbest = CM
  1141. CM_logbase10, CM_logbase10str = calcCM(pValue, AUC, accuracy, p_value_logbase=10) # for scoring/optimization purposes we may the AUC-only CM, but for reporting we always return the CM_logbase10 so it's comparable to other runs
  1142. dict_best = {'nu':nu, 'pValue':pValue, 'Pstr':formatPstring(pValue), 'AUC':AUC, 'AUCstr':AUCstr, 'accuracy':accuracy, 'accuracyStr':accuracyStr, 'f1':f1, 'CM':CM, 'CMstr':CMstr, 'CM_logbase10':CM_logbase10, 'CM_logbase10str':CM_logbase10str, 'decision_function_df':decision_function_df, 'y_pred':y_classification}
  1143. except:
  1144. if suppressWarnings == False: printSB('nuSVC_CM exception at nu = {:.2f}'.format(nu) + ': method terminating early (this may be OK)')
  1145. # sort by CM
  1146. #sorted_dList = sorted(dict_out_list, key=lambda x: x['CM'], reverse=True) # sort by CM: sorted_dList[0] is the best dict
  1147. return dict_best # return the dict with best results (see above for keys). get elements of dict like this: d_best.get('decision_function_df')
  1148. ### computes nu-support vector classification on X & Y, selects the best hyperparams based on CM (composite metric)
  1149. ### pass subject-wise mean PCs in PCmeans_df: 1st col is name, 2nd col is classID, rest are PCs
  1150. ### needs global vars: maxIter_svm, param_grid for grid search, kStandardScaleResult
  1151. # 2024-04-23: CM now includes accuracy as well as log(P) and AUC
  1152. def SVC_CM(PCmeans_df, param_grid, n_splits=10, n_repeats=5, suppressWarnings=False, defaultLabel='PC', p_value_logbase=10):
  1153. # PCmeans_df can be UMAP means, etc
  1154. from sklearn.svm import SVC
  1155. from sklearn.model_selection import GridSearchCV, RepeatedKFold
  1156. from sklearn import metrics
  1157. from sklearn.preprocessing import StandardScaler
  1158. rkf = RepeatedKFold(n_splits=min(n_splits,PCmeans_df.shape[0]), n_repeats=n_repeats)
  1159. scaler = StandardScaler()
  1160. dict_best = {'CM':-1} # in case no solution/exception
  1161. first_PC_index = next((i for i, col in enumerate(PCmeans_df.columns) if col.startswith(defaultLabel)), -1) # find index of 1st PCx or UMAPx etc column
  1162. if first_PC_index == -1:
  1163. printSB('SVC_CM: PCx columns could not be found (fatal error)')
  1164. return dict_best, pd.DataFrame()
  1165. X = PCmeans_df.iloc[:,first_PC_index:]
  1166. Y = PCmeans_df['classID']
  1167. svm_model = SVC()
  1168. try: # some values of nu are illegal and will throw an exception: catch it and return what we have
  1169. grid_search = GridSearchCV(estimator=svm_model, param_grid=param_grid, cv=rkf)
  1170. grid_search.fit(X, Y)
  1171. y_pred = grid_search.predict(X) # make predictions on the input set to check accurcy of linear separability
  1172. decision_function = grid_search.decision_function(X)
  1173. if kStandardScaleResult:
  1174. decision_function = scaler.fit_transform(decision_function.reshape(-1, 1)) # often decision_function contains tiny values so rescaling standardizes when averaging with different hyperparameters
  1175. decision_function_df = pd.DataFrame(data=decision_function,columns=['score'])
  1176. #printSB('nuSVC_CM: about to stats.ttest_ind')
  1177. # now compute t-test for dec fn, AUC -> CM
  1178. a = decision_function_df.loc[PCmeans_df['classID'] == 0]['score']
  1179. b = decision_function_df.loc[PCmeans_df['classID'] == 1]['score']
  1180. _, pValue = stats.ttest_ind(a, b, equal_var = True, alternative='less') #run independent 2 sample T-Test
  1181. #printSB('nuSVC_CM: about to metrics.roc_auc_score')
  1182. # AUC
  1183. #predicted_probabilities = decision_function_df['score'] THIS IS WRONG: because dec_func varies from <0 to >0 (either side of the hyperplane) it is not a 0-1 probability which is what metrics.roc_auc_score expects
  1184. y_classification = np.where(decision_function_df['score'] > 0, 1, 0) # so convert it to a predicted 0 or 1 class, depending on <0 vs >0
  1185. actual_labels = PCmeans_df['classID']
  1186. AUC = metrics.roc_auc_score(actual_labels, y_classification)
  1187. if AUC == 1.0:
  1188. AUCstr = 'AUC = 1.0'
  1189. else:
  1190. AUCstr = 'AUC = {:.2f}'.format(AUC)
  1191. #printSB('nuSVC_CM: about to metrics.accuracy_score')
  1192. # accuracy
  1193. accuracy = metrics.accuracy_score(actual_labels, y_classification)
  1194. if accuracy == 1.0:
  1195. accuracyStr = 'accuracy=1.0'
  1196. else:
  1197. accuracyStr = 'accuracy={:.2f}'.format(accuracy)
  1198. # f1 score
  1199. f1 = metrics.f1_score(actual_labels, y_classification)
  1200. # composite metric
  1201. CM, CMstr = calcCM(pValue, AUC, accuracy, p_value_logbase=p_value_logbase) # Higher values of p_value_logbase emphasize AUC over P value
  1202. dict_best = {'C':grid_search.best_params_['C'], 'gamma':grid_search.best_params_['gamma'], 'kernel':grid_search.best_params_['kernel'], 'pValue':pValue, 'Pstr':formatPstring(pValue), 'AUC':AUC, 'AUCstr':AUCstr, 'accuracy':accuracy, 'accuracyStr':accuracyStr, 'f1':f1, 'CM':CM, 'CMstr':CMstr, 'decision_function_df':decision_function_df, 'y_pred':y_classification}
  1203. except:
  1204. if suppressWarnings == False: printSB('SVC_CM exception')
  1205. # sort by CM
  1206. #sorted_dList = sorted(dict_out_list, key=lambda x: x['CM'], reverse=True) # sort by CM: sorted_dList[0] is the best dict
  1207. return dict_best # return the dict with best results (see above for keys). get elements of dict like this: d_best.get('decision_function_df')
  1208. # scikit-learn.org/stable/modules/generated/sklearn.discriminant_analysis.QuadraticDiscriminantAnalysis.html
  1209. def QDA(PCmeans_df, p_value_logbase=10):
  1210. from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis
  1211. from sklearn import metrics
  1212. from sklearn.preprocessing import StandardScaler
  1213. scaler = StandardScaler()
  1214. X = PCmeans_df.iloc[:,2:]
  1215. Y = PCmeans_df.iloc[:,1]
  1216. clf = QuadraticDiscriminantAnalysis()
  1217. clf.fit(X, Y)
  1218. y_pred = clf.predict(X)
  1219. class1probs = clf.predict_proba(X)[:,1] # probabilities of class1 membership https://scikit-learn.org/stable/modules/generated/sklearn.discriminant_analysis.QuadraticDiscriminantAnalysis.html#sklearn.discriminant_analysis.QuadraticDiscriminantAnalysis.predict_proba
  1220. AUC = metrics.roc_auc_score(Y, y_pred)
  1221. if AUC == 1.0:
  1222. AUCstr = 'AUC = 1.0'
  1223. else:
  1224. AUCstr = 'AUC = {:.2f}'.format(AUC)
  1225. #accuracy = metrics.accuracy_score(y, y_pred)
  1226. accuracy = clf.score(X, Y)
  1227. if accuracy == 1.0:
  1228. accuracyStr = 'accuracy=1.0'
  1229. else:
  1230. accuracyStr = 'accuracy={:.2f}'.format(accuracy)
  1231. y_classification = np.where(class1probs > 0.5, 1, 0) # class0 if probability <0.5, else class1
  1232. probability_accuracy = metrics.accuracy_score(Y, y_classification)
  1233. if probability_accuracy == 1.0:
  1234. probability_accuracyStr = 'accuracy=1.0'
  1235. else:
  1236. probability_accuracyStr = 'accuracy={:.2f}'.format(probability_accuracy)
  1237. decision_function = clf.decision_function(X)
  1238. decision_function = scaler.fit_transform(decision_function.reshape(-1, 1)) # often decision_function contains tiny values so rescaling standardizes when averaging with different hyperparameters
  1239. decision_function_df = pd.DataFrame(data=decision_function,columns=['score'])
  1240. # now compute t-test for dec fn, AUC -> CM
  1241. a = decision_function_df.loc[PCmeans_df['classID'] == 0]['score']
  1242. b = decision_function_df.loc[PCmeans_df['classID'] == 1]['score']
  1243. _, pValue = stats.ttest_ind(a, b, equal_var = True, alternative='less') #run independent 2 sample T-Test
  1244. # composite metric
  1245. CM, CMstr = calcCM(pValue, AUC, accuracy, p_value_logbase=p_value_logbase) # Higher values of p_value_logbase emphasize AUC over P value
  1246. # f1 score
  1247. f1 = metrics.f1_score(Y, y_pred)
  1248. dict_out = {'pValue':pValue, 'Pstr':formatPstring(pValue), 'AUC':AUC, 'AUCstr':AUCstr, 'accuracy':accuracy, 'accuracyStr':accuracyStr, 'f1':f1, 'CM':CM, 'CMstr':CMstr, 'decision_function_df':decision_function_df, 'y_pred':y_pred, 'class1probs':class1probs, 'probability_accuracy':probability_accuracy, 'probability_accuracyStr':probability_accuracyStr}
  1249. return dict_out
  1250. # pass a multivariate ie spectra over several T points as in a Duetta/Mk5 PC series
  1251. # will average the spectra at the chosen T points and subract this average from all spectra, returning a new bsub'd dataframe (df_multivariate_ALLcols_bsub)
  1252. # also returns the mean baseline spectrum used for the bsub for each name (df_baselineSpectra)
  1253. # df_baselineSourceSpectra are spectral T series from which averages will be calculated, typically df_multivariate but with ALL T points included (ie Tend=-1 equivalent)
  1254. def baselineSubtract(df_multivariate, df_baselineSourceSpectra, Tavg_startIx, Tavg_endIx): # Tpoints Tavg_startIx, Tavg_endIx (0-based, inclusive) used to compute mean spectrum for baseine subtraction
  1255. namesUnique = df_multivariate['name'].unique() # unique names from 'name' col
  1256. colNames = df_multivariate.columns # original colnames, ALL cols
  1257. for j in range(namesUnique.shape[0]):
  1258. #for j in range(1):
  1259. name = namesUnique[j]
  1260. # printSB(j,name)
  1261. df_baselineSourceSpectra_byname = df_baselineSourceSpectra[df_baselineSourceSpectra['name']==name] # extract all rows at all Ts by name from the ALL-in df
  1262. df_baselineSourceSpectra_byname_spectra = df_baselineSourceSpectra_byname.iloc[:,spectrum1stColIndex:] # just the spectral cols
  1263. baselineRows = df_baselineSourceSpectra_byname.iloc[Tavg_startIx:Tavg_endIx+1,] # the rows that will be averaged and used for subtraction
  1264. baselineRows.reset_index(inplace=True,drop=True)
  1265. baselineRows_spectra = baselineRows.iloc[:,spectrum1stColIndex:] # just the spectral cols
  1266. baselineMean = baselineRows_spectra.mean(axis=0)
  1267. baselineMean_df = pd.DataFrame(baselineMean).T # make a df and transpose from 730 rows,1 col to 1 row,730 cols
  1268. df_multivariate_byname = df_multivariate[df_multivariate['name']==name] # extract all rows at all Ts by name
  1269. df_multivariate_byname_spectra = df_multivariate_byname.iloc[:,spectrum1stColIndex:] # just the spectral cols
  1270. df_multivariate_byname_spectra_bsub = df_multivariate_byname_spectra.copy() # this will be the bsub'd table
  1271. df_multivariate_byname_spectra_bsub -= baselineMean # bsub'd spectral rows for name at all Ts
  1272. metaCols_byname = df_multivariate[df_multivariate['name']==name].iloc[:,0:spectrum1stColIndex] # the 1st 3 meta cols (name, classID, T) at all Ts for name
  1273. df_multivariate_byname_bsub = pd.concat([metaCols_byname,df_multivariate_byname_spectra_bsub], axis=1, ignore_index=True) # concat (by columns) the metaColumns and the bsub'd spectral columns
  1274. df_multivariate_byname_bsub.columns = colNames # essential else concats fail below
  1275. metaCols_byname_1stRow = metaCols_byname.iloc[0:1] # just 1st row, for building df_baselineSpectra
  1276. metaCols_byname_1stRow.reset_index(inplace=True,drop=True)
  1277. baselineSpectrum_byname = pd.concat([metaCols_byname_1stRow,baselineMean_df], axis=1)
  1278. baselineSpectrum_byname.columns = colNames
  1279. if j == 0:
  1280. df_multivariate_ALLcols_bsub = pd.DataFrame(data=df_multivariate_byname_bsub)
  1281. df_multivariate_ALLcols_bsub.columns = colNames # essential else concats fail below
  1282. df_baselineSpectra = pd.DataFrame(data=baselineSpectrum_byname)
  1283. df_baselineSpectra.columns = colNames # essential else concats fail below
  1284. else:
  1285. df_multivariate_ALLcols_bsub = pd.concat([df_multivariate_ALLcols_bsub, df_multivariate_byname_bsub], axis=0, ignore_index=True) # concat (by rows) into the master bsub'd df
  1286. df_baselineSpectra = pd.concat([df_baselineSpectra, baselineSpectrum_byname], axis=0, ignore_index=True)
  1287. df_multivariate_ALLcols_bsub.reset_index(inplace=True,drop=True)
  1288. df_baselineSpectra.reset_index(inplace=True,drop=True)
  1289. return df_multivariate_ALLcols_bsub, df_baselineSpectra
  1290. def generate_balanced_random_0sand1s(N):
  1291. import random
  1292. if N % 2 != 0: N+= 1
  1293. half_N = N // 2
  1294. zeros = ones = half_N
  1295. result = [0] * zeros + [1] * ones
  1296. random.shuffle(result)
  1297. return result
  1298. def printNamesAndClassIDs(df, prependLF=False, appendLF=False):
  1299. if prependLF: printSB()
  1300. #printSB(df.drop_duplicates(subset=['name', 'classID']).copy().iloc[:,0:2].to_string(index=False))
  1301. # Group by 'name' and 'classID', count the occurrences, and reset the index
  1302. result_df = df.groupby(['name', 'classID']).size().reset_index(name='count')
  1303. # Rename the columns for clarity
  1304. result_df = result_df.rename(columns={'name': 'Name', 'classID': 'ClassID', 'count': 'Count'})
  1305. # Print the result in a nicely formatted table
  1306. printSB(result_df.to_string(index=False))
  1307. if appendLF: printSB()
  1308. return
  1309. def randomizeImageData(df, unique_names_classIDs, randflag=1, printNamesAndIDs=True, verbose=1):
  1310. # randomizes df classIDs by name (ie multiple rows with same name will be randomized together to same new classID), returns a copy of df and the appropriate randSuffix
  1311. # randflag selects 1 of 3 modes (see code)
  1312. # requires: unique_names_classIDs, typically: imageData.drop_duplicates(subset=['name', 'classID']).copy().iloc[:,0:2] # unique names and matching classID cols
  1313. # set verbose=0 to suppress all printing
  1314. # all columns in df_rand will be the same as df, except the 'classID' col will be shuffled
  1315. randSuffix = ''
  1316. df_rand = df.copy() # if randflang=0 just returns a copy of df
  1317. if randflag == 1: # rand using generate_balanced_random_0sand1s()
  1318. if verbose > 0: printSB('Randomizing classIDs using generate_balanced_random_0sand1s...')
  1319. randSuffix = ' RAND'
  1320. randClassList = generate_balanced_random_0sand1s(unique_names_classIDs.shape[0])
  1321. for j, name in enumerate(unique_names_classIDs['name']):
  1322. randClassID = randClassList[j] # random 0 or 1 sequence (balanced)
  1323. df_rand.loc[df['name'] == name,['classID']] = randClassID # assign classID = randClassID to all rows whose name = name
  1324. elif randflag == 2: # mod 2 classID
  1325. if verbose > 0: printSB('Randomizing classIDs using mod 2...')
  1326. randSuffix = ' RAND'
  1327. unique_names_classIDs_RAND = unique_names_classIDs.sample(frac=1).reset_index(drop=True) # shuffle the rows of unique_names_classIDs into a copy
  1328. unique_names_classIDs_RAND.sort_values(by=['classID'],inplace=True,ignore_index=True) # sort by classID only: names will remain shuffled WITHIN a class, eliminating the same randomization for each pass mod 2
  1329. for j, name in enumerate(unique_names_classIDs_RAND['name']):
  1330. randClassID = j % 2 # mod 2 classID for name
  1331. df_rand.loc[df['name'] == name,['classID']] = randClassID # assign classID = randClassID to all rows whose name = name
  1332. elif randflag == 3: # mod 2 classID but names sorted and NOT randomized before classID sort
  1333. if verbose > 0: printSB('Randomizing classIDs using mod 2...')
  1334. randSuffix = ' RAND'
  1335. unique_names_classIDs_RAND = unique_names_classIDs.copy().reset_index(drop=True) # shuffle the rows of unique_names_classIDs into a copy
  1336. unique_names_classIDs_RAND.sort_values(by=['classID','name'],inplace=True,ignore_index=True) # sort by classID first (so mod 2 re-assignment will produce a balanced RAND), then by name so same randomization will occur in contrast to randflag = 2
  1337. for j, name in enumerate(unique_names_classIDs_RAND['name']):
  1338. randClassID = j % 2 # mod 2 classID for name
  1339. df_rand.loc[df['name'] == name,['classID']] = randClassID # assign classID = randClassID to all rows whose name = name
  1340. if printNamesAndIDs and (verbose > 0): printNamesAndClassIDs(df_rand, True, True)
  1341. return df_rand, randSuffix # df was randomized as a copy
  1342. def bsub(raw_df):
  1343. # detects spectra with classID = -1, averages all these as a unvariate set
  1344. # subtracts this mean dark spectrum from all other rows
  1345. # build NON-norm univariate vectors
  1346. # build a 2D univariate features array of shape (n_instances, nL*nT)
  1347. names_unique = raw_df['name'].unique()
  1348. n_subjects = names_unique.shape[0]
  1349. nT_original = raw_df['T'].unique().shape[0]
  1350. n_lambdas_raw = raw_df.columns[spectrum1stColIndex:].shape[0] # lambdas from raw input data before range pruning etc
  1351. spectralData_NONnorm_univariate = np.zeros((n_subjects, n_lambdas_raw*nT_original)) # 2D np.ndarray,
  1352. #names_unique
  1353. names = []
  1354. rootnames = []
  1355. classIDs = []
  1356. for instanceCtr, name in enumerate(names_unique): # counts subjects
  1357. rowsByName = spectralData_raw.loc[spectralData_raw['name']==name]
  1358. rowsByName.reset_index(inplace=True, drop=True) # all rows with name = name
  1359. classID = rowsByName['classID'].iloc[0]
  1360. names.append(name)
  1361. rootnames.append(extractRootname(name)) # Split the string at the last occurrence of sep, and return a 3-tuple containing the part before the separator, the separator itself, and the part after the separator (https://docs.python.org/2/library/stdtypes.html#str.rpartition)
  1362. classIDs.append(classID)
  1363. spectralValues_NONnorm_allPerSubjectList = [] # we will flatten all spectra from all T points into a single row for each subject
  1364. for Tctr in range(nT_original):
  1365. rowByT = rowsByName.iloc[Tctr]
  1366. spectralValuesAtT = rowByT[spectrum1stColIndex:spectrum1stColIndex+n_lambdas_raw].to_numpy() # extracted spectrum at time Tctr
  1367. spectralValues_NONnorm_allPerSubjectList.append(spectralValuesAtT)
  1368. spectralValues_NONnorm_allPerSubject_np = np.asarray(spectralValues_NONnorm_allPerSubjectList).flatten()
  1369. spectralData_NONnorm_univariate[instanceCtr,:] = spectralValues_NONnorm_allPerSubject_np
  1370. ### spectralData_cond_univariate now contains the univariate 2D array: dim 1=subjects (incl replicates); dim 2=dummy; dim 3=lambda bins
  1371. # assemble into a df
  1372. spectrum1stColIndex_univariate = 2
  1373. names_df = pd.DataFrame(data=names,columns=['name'])
  1374. classIDs_df = pd.DataFrame(data=classIDs,columns=['classID'])
  1375. spectralData_NONnorm_univariate_df = pd.DataFrame(data=spectralData_NONnorm_univariate)
  1376. spectralData_NONnorm_univariate_df = pd.concat([names_df,classIDs_df,spectralData_NONnorm_univariate_df],axis=1)
  1377. # extract all the univariate dark spectra ie classID=-1
  1378. dark_df = spectralData_NONnorm_univariate_df.loc[spectralData_NONnorm_univariate_df['classID'] == -1]
  1379. # extract spectral columns
  1380. dark_spectra_df = dark_df.iloc[:,2:]
  1381. # average all the dark rows
  1382. dark_mean_df = dark_spectra_df.mean()
  1383. # extract all the univariate NONdark spectra ie classID>-1 as copies
  1384. NONdark_df = spectralData_NONnorm_univariate_df.loc[spectralData_NONnorm_univariate_df['classID'] > -1].copy()
  1385. NONdark_df.reset_index(inplace=True, drop=True)
  1386. NONdark_spectra_df = NONdark_df.iloc[:,2:] # spectral cols only
  1387. NONdark_bsub_spectra_df = NONdark_spectra_df - dark_mean_df # dark subtract
  1388. NONdark_bsub_spectra_df[NONdark_bsub_spectra_df < 0] = 0 # fix neg values
  1389. NONdark_df = pd.concat([NONdark_df.iloc[:,0:2],NONdark_bsub_spectra_df],axis=1) # reassemble with meta columns
  1390. #NONdark_df.columns = cols_orig # restore col names ie wavelengths
  1391. # reshape into a multivariate df by T
  1392. raw_multivariate_df = raw_df.iloc[0:1,:].copy() # copy 1st row of spectralData_raw to get the corrrect cols and their names: we'll replace the values from bsub'd df and append new ones
  1393. raw_multivariate_1stRow = raw_multivariate_df.copy()
  1394. nT_original = raw_df['T'].unique().shape[0]
  1395. n_lambdas_raw = raw_df.columns[spectrum1stColIndex:].shape[0] # no. lambdas from raw input data before range pruning etc
  1396. for rowCtr in range(NONdark_df.shape[0]):
  1397. row_univariate = NONdark_df.iloc[rowCtr,:] # next entire univariate row for next subject
  1398. name = row_univariate.iloc[0]
  1399. classID = row_univariate.iloc[1]
  1400. # printSB(name)
  1401. for tCtr in range(nT_original):
  1402. if (rowCtr==0) and (tCtr==0): # 1st multivariate row is special
  1403. raw_multivariate_df.iloc[0,0:1] = name
  1404. raw_multivariate_df.iloc[0,1:2] = classID
  1405. raw_multivariate_df.iloc[0,2:3] = tCtr
  1406. raw_multivariate_df.iloc[0,3:] = row_univariate[2+tCtr*n_lambdas_raw:2+(tCtr+1)*n_lambdas_raw]
  1407. else:
  1408. raw_multivariate_newRow = raw_multivariate_1stRow.copy()
  1409. raw_multivariate_newRow.iloc[0,0:1] = name
  1410. raw_multivariate_newRow.iloc[0,1:2] = classID
  1411. raw_multivariate_newRow.iloc[0,2:3] = tCtr
  1412. raw_multivariate_newRow.iloc[0,3:] = row_univariate[2+tCtr*n_lambdas_raw:2+(tCtr+1)*n_lambdas_raw]
  1413. raw_multivariate_df = pd.concat([raw_multivariate_df,raw_multivariate_newRow], axis=0, ignore_index=True) # append the new bsub'd multivariate row
  1414. return raw_multivariate_df
  1415. # load multiple CSVs into a single df. Typically get csvList from get_child_files()
  1416. def loadCSVs(csvList):
  1417. for n, csv in enumerate(csvList):
  1418. # printSB(n,csv)
  1419. if n == 0:
  1420. # read 1st line: do we have a comment?
  1421. with open(csv,'r') as f:
  1422. csvLine1 = f.readline() # csvLine1 might be a comment
  1423. if csvLine1[:1] != "'": # must match app.kPandasCSVcommentChar in ITRK and SB
  1424. csvLine1 = '' # if not a comment we don't want to dbl-write line1 below
  1425. printSB('Loading: ' + os.path.basename(csv) + '...')
  1426. if n == 0:
  1427. df = pd.read_csv(csv, comment="'") # skip lines with '
  1428. columns0 = df.columns
  1429. else: # append
  1430. dfN = pd.read_csv(csv, comment="'")
  1431. if (columns0 != dfN.columns).any(): # if any of the columns differ
  1432. printSB('*** WARNING: columns for ' + os.path.basename(csv) + ' differ from 1st csv: resetting to columns of 1st csv. Make sure this is correct!')
  1433. dfN.columns = columns0
  1434. df = pd.concat([df,dfN],axis=0,ignore_index=True)
  1435. return df, csvLine1 # return the combined df and the csv line1 comment, if any
  1436. def loadCSVs_v2(csvList, startChar='L', stripLeadingLs=True, printLoading=True):
  1437. # v2 autodetects 'L' lambda columns
  1438. for n, csv in enumerate(csvList):
  1439. # printSB(n,csv)
  1440. if n == 0:
  1441. # read 1st line: do we have a comment?
  1442. with open(csv,'r') as f:
  1443. csvLine1 = f.readline() # csvLine1 might be a comment
  1444. if csvLine1[:1] != "'": # must match app.kPandasCSVcommentChar in ITRK and SB
  1445. csvLine1 = '' # if not a comment we don't want to dbl-write line1 below
  1446. if printLoading: printSB('Loading: ' + os.path.basename(csv) + '...')
  1447. if n == 0:
  1448. df = pd.read_csv(csv, comment="'") # skip lines with '
  1449. columns0 = df.columns
  1450. else: # append
  1451. dfN = pd.read_csv(csv, comment="'")
  1452. if (columns0 != dfN.columns).any(): # if any of the columns differ
  1453. printSB('*** WARNING: columns for ' + os.path.basename(csv) + ' differ from 1st csv: resetting to columns of 1st csv. Make sure this is correct!')
  1454. dfN.columns = columns0
  1455. df = pd.concat([df,dfN],axis=0,ignore_index=True)
  1456. firstLambdaCol, nLambdas, lambdaList = get_column_indices(df)
  1457. if nLambdas == 0: # 'L' cols were not detected
  1458. firstLambdaCol, nLambdas = -1, -1
  1459. elif stripLeadingLs: # 'L' cols were detected, strip the leading 'L's from their names
  1460. df.columns = df.columns.str.replace('^' + startChar, '', regex=True)
  1461. return df, csvLine1, firstLambdaCol, nLambdas, lambdaList # return the combined df and the csv line1 comment, if any
  1462. def loadCSVs_v3(csvList, startChar='L', stripLeadingLs=True, printLoading=True):
  1463. # v2 autodetects 'L' lambda columns
  1464. # v3: returns raw col names for reference
  1465. for n, csv in enumerate(csvList):
  1466. # printSB(n,csv)
  1467. if n == 0:
  1468. # read 1st line: do we have a comment?
  1469. with open(csv,'r') as f:
  1470. csvLine1 = f.readline() # csvLine1 might be a comment
  1471. if csvLine1[:1] != "'": # must match app.kPandasCSVcommentChar in ITRK and SB
  1472. csvLine1 = '' # if not a comment we don't want to dbl-write line1 below
  1473. if printLoading: printSB('Loading: ' + os.path.basename(csv) + '...')
  1474. if n == 0:
  1475. df = pd.read_csv(csv, comment="'") # skip lines with '
  1476. columns0 = df.columns
  1477. else: # append
  1478. dfN = pd.read_csv(csv, comment="'")
  1479. if (columns0 != dfN.columns).any(): # if any of the columns differ
  1480. printSB('*** WARNING: columns for ' + os.path.basename(csv) + ' differ from 1st csv: resetting to columns of 1st csv. Make sure this is correct!')
  1481. dfN.columns = columns0
  1482. df = pd.concat([df,dfN],axis=0,ignore_index=True)
  1483. firstLambdaCol, nLambdas, lambdaList = get_column_indices(df)
  1484. if nLambdas == 0: # 'L' cols were not detected
  1485. firstLambdaCol, nLambdas = -1, -1
  1486. elif stripLeadingLs: # 'L' cols were detected, strip the leading 'L's from their names
  1487. df.columns = df.columns.str.replace('^' + startChar, '', regex=True)
  1488. return df, csvLine1, firstLambdaCol, nLambdas, lambdaList, columns0 # return the combined df and the csv line1 comment, if any. Also return the raw col names
  1489. # main csv loader function
  1490. def readCSVs(csvIN, dirIN, dirOUT, nowStr, classList, intensityLO, intensityHI, lambdaSTART, lambdaEND, replaceNameUnderscoresWithDashes, subSampleSize, augX=1, T=[0]):
  1491. # delete all rows whose classID is not in classList (pass None or empty classList to accept all classes)
  1492. # if csv has a 'T' column, include only rows where T=T; pass -1 to include all T columns;can pass T=[startIx,endIx] with inclusive 0-based indexes of T columns to retain
  1493. # if augX < 0 uses augPow2df augmentation
  1494. if dirOUT=='':
  1495. parentFolder = os.path.dirname(csvIN)
  1496. else:
  1497. parentFolder = dirOUT
  1498. if dirIN == '':
  1499. fBaseExt = os.path.basename(csvIN) # includes .csv extension
  1500. fBaseNoExt = Path(csvIN).stem # w/o extension
  1501. else:
  1502. fBaseExt = 'MULTIPLE_FROM:' + Path(dirIN).stem
  1503. fBaseNoExt = fBaseExt
  1504. if nowStr == '':
  1505. datetimeSuffix = ''
  1506. else:
  1507. datetimeSuffix = '_' + nowStr
  1508. #printSB("Loading " + csvIN + "...")
  1509. csvList = get_child_files(dirIN, csvIN, 'csv')
  1510. imageData_df, csvLine1, firstLambdaCol, nLambdas, _ = loadCSVs_v2(csvList) # v2 will autodetect new 'L' lambda columns, if not firstLambdaCol, nLambdas returned as -1, so regular code below will do the old auto-detection
  1511. if len(csvList) == 1:
  1512. printSB("1 CSV LOADED. Total {:,} rows".format(imageData_df.shape[0]))
  1513. else:
  1514. printSB(str(len(csvList)) + " CSVs LOADED. Total {:,} rows".format(imageData_df.shape[0]))
  1515. headerRow, firstLambdaCol, nLambdas = fixOrangeHeaders(imageData_df)
  1516. for h in headerRow:
  1517. if ('unique4' in h):
  1518. imageData_df.rename(columns = {h:'unique4'}, inplace = True)
  1519. break
  1520. allClasses_original = imageData_df.classID.unique() # list of all classes in the original csv's before filtering
  1521. #imageData_df.loc[imageData_df['classID'] == 2, 'classID'] = 1 # you can reset one class to another eg all class2 rows reset to class1 (say you want to combine class1&2 in the analysis)
  1522. ignoreClassList = (classList is None) or (len(classList)==0)
  1523. if ignoreClassList == False:
  1524. imageData_df = imageData_df[imageData_df['classID'].isin(classList)] # filter only rows whose classIDs are in the classPair list
  1525. nClasses = imageData_df.classID.unique().shape[0]
  1526. hasTcol = ('T' in headerRow)
  1527. if hasTcol:
  1528. if (len(T)==1) and (T[0] > -1): # is T a single integer?
  1529. imageData_df = imageData_df[imageData_df['T'] == T[0]] # filter rows by a single T
  1530. elif len(T)==2: # list of start/end indexes?
  1531. # Filter the DataFrame to keep rows where T is within the inclusive range
  1532. imageData_df = imageData_df[(imageData_df['T'] >= T[0]) & (imageData_df['T'] <= T[1])]
  1533. else:
  1534. raise ValueError("Illegal T param in readCSVs method")
  1535. adjustColumnTypes(imageData_df, firstLambdaCol, nLambdas, cleanLambdaColHeaders=True, promoteLambdaColsToFloat64=True) # reduce memory footprint
  1536. # restrict by intensity, etc
  1537. #imageData_df = imageData_df.loc[(imageData_df['intensity'] > 70)]
  1538. if 'intensity' in imageData_df.columns: # mk5/6 csv's may not have intensity col
  1539. imageData_df = imageData_df.loc[(imageData_df['intensity'] >= intensityLO) & (imageData_df['intensity'] < intensityHI)]
  1540. imageData_df.reset_index(inplace=True, drop=True)
  1541. nLambdas, imageData_df = restrictLambdaRange(imageData_df, lambdaSTART, lambdaEND, firstLambdaCol, nLambdas)
  1542. printSB('firstLambdaCol: ' + str(firstLambdaCol))
  1543. printSB('nLambdas: ' + str(nLambdas))
  1544. # Extract lambda headers
  1545. lambda_headers = imageData_df.columns[firstLambdaCol:firstLambdaCol + nLambdas]
  1546. # Convert headers to floats
  1547. lambdas_np = np.array(lambda_headers, dtype=float)
  1548. rootNames, rootNames_df = extractRootnames(imageData_df, '_AUG_') # in case need pre-augmented subject rootnames
  1549. if replaceNameUnderscoresWithDashes:
  1550. imageData_df['name'] = imageData_df['name'].str.replace('_','-')
  1551. # subsample for debugging
  1552. if subSampleSize > 0:
  1553. subSampleSize = min(subSampleSize,imageData_df.shape[0])
  1554. imageData_df = imageData_df.sample(n=subSampleSize, ignore_index=True, random_state=123) # ignore_index=True is essential
  1555. imageData_df.sort_values(by=['classID','name','unique4'],inplace=True,ignore_index=True) # for human user, not necessary for embedding. ignore_index=True is critical else you end up with a randomized frame somehow
  1556. if augX < 0:
  1557. augPow2 = -augX
  1558. printSB(f'Augmenting data by pow2X ({2**augPow2} total uniques)...')
  1559. imageData_df, _ = augPow2(dfIN, augPow2) # pow2 data augmentation
  1560. elif augX > 1:
  1561. printSB('Augmenting data ' + str(augX) + 'X...')
  1562. imageData_df, _ = augXdf(imageData_df, augX) # data augmentation
  1563. elif augX == 0: # special case for Mark6
  1564. printSB('Augmenting each kernel to a unique instance...')
  1565. imageData_df = aug0df(imageData_df) # per-kernel data augmentation
  1566. names_unique = imageData_df['name'].unique()
  1567. N_names_unique = names_unique.shape[0]
  1568. unique_names_classIDs = imageData_df.drop_duplicates(subset=['name', 'classID']).copy().iloc[:,0:2] # unique names and matching classID cols
  1569. unique_names_classIDs.reset_index(inplace=True, drop=True)
  1570. d = {}
  1571. d['imageData'] = imageData_df
  1572. d['fBaseExt'] = fBaseExt
  1573. d['fBaseNoExt'] = fBaseNoExt
  1574. d['parentFolder'] = parentFolder
  1575. d['datetimeSuffix'] = datetimeSuffix
  1576. d['csvLine1'] = csvLine1
  1577. d['firstLambdaCol'] = firstLambdaCol
  1578. d['nLambdas'] = nLambdas
  1579. d['lambdas_np'] = lambdas_np # lambdas an a np array
  1580. d['nClasses'] = nClasses # after filtering
  1581. d['allClasses_original'] = allClasses_original # before filtering
  1582. d['rootNames'] = rootNames
  1583. d['names_unique'] = names_unique
  1584. d['N_names_unique'] = N_names_unique
  1585. d['unique_names_classIDs'] = unique_names_classIDs
  1586. return d # return results as a dict
  1587. # merge multiple CSVs in a folder and write to a new csv in same folder
  1588. def mergeCSVs(directory_path, dirOut=''):
  1589. """
  1590. Combines all CSV files within a directory into a single DataFrame, then saves it.
  1591. Args:
  1592. directory_path (str): The path to the directory containing the CSV files.
  1593. """
  1594. all_files = os.listdir(directory_path)
  1595. csv_files = [file for file in all_files if file.endswith('.csv')]
  1596. dataframes = []
  1597. for n, file in enumerate(csv_files):
  1598. filepath = os.path.join(directory_path, file)
  1599. printSB('Loading: ' + os.path.basename(file) + '...')
  1600. df = pd.read_csv(filepath)
  1601. if n == 0:
  1602. columns0 = df.columns
  1603. else:
  1604. if (columns0 != df.columns).any(): printSB('*** WARNING: columns for ' + os.path.basename(file) + ' differ from 1st csv: resetting to columns of 1st csv. Make sure this is correct!') # if any of the columns differ
  1605. dataframes.append(df)
  1606. combined_df = pd.concat(dataframes, ignore_index=True)
  1607. output_filename = f"{len(csv_files)} merged.csv"
  1608. if dirOut == '':
  1609. output_filepath = os.path.join(directory_path, output_filename)
  1610. else:
  1611. output_filepath = os.path.join(dirOut, output_filename)
  1612. printSB('Saving merged CSV to: ' + output_filename + '...')
  1613. combined_df.to_csv(output_filepath, index=False)
  1614. return output_filepath
  1615. # merge multiple CSVs in a list and write to a new csv in same folder
  1616. def mergeCSVsFromList(csvList, dirOut):
  1617. """
  1618. Combines all CSV files in csvList into a single DataFrame, then saves it.
  1619. Args:
  1620. listOfCSVs (str list):
  1621. """
  1622. imageData, csvLine1, firstLambdaCol, nLambdas, _ = loadCSVs_v2(csvList)
  1623. output_filename = f"{len(csvList)} merged.csv"
  1624. output_filepath = os.path.join(dirOut, output_filename)
  1625. printSB('\nSaving merged CSV to: ' + output_filename + ' (' + str(imageData.shape[0]) + ' rows)...')
  1626. imageData.to_csv(output_filepath, index=False)
  1627. return output_filepath
  1628. def Raman_blank(spectra_df, spectrum1stColIndex, raman_nm_start, raman_nm_end):
  1629. # blanks the Raman water spectrum by linearly interpolating all spectra in spectra_df between raman_nm_start & raman_nm_end nm
  1630. # spectrum1stColIndex: index of 1st column of spectral data after all meta columns
  1631. if (raman_nm_start<=0) or (raman_nm_end<=0): return spectra_df # set raman_nm_start or raman_nm_end to 0 to omit blanking
  1632. nmArr = spectra_df.columns[spectrum1stColIndex:].astype('float') # wavelengths
  1633. metaCols = spectra_df.iloc[:,:spectrum1stColIndex] # non-spectral meta cols only
  1634. df = spectra_df.iloc[:,spectrum1stColIndex:] # spectral cols only
  1635. df.reset_index(inplace=True, drop=True) # don't forget this if you want to re-index rows from 0
  1636. # find the index of the column @ raman_nm_start
  1637. for j in range(nmArr.shape[0]):
  1638. if nmArr[j] >= raman_nm_start:
  1639. raman_nm_start_ix = j
  1640. break
  1641. # find the index of the column @ raman_nm_end
  1642. for j in range(nmArr.shape[0]-1, -1, -1):
  1643. if nmArr[j] <= raman_nm_end:
  1644. raman_nm_end_ix = j
  1645. break
  1646. new_df = df.copy() # make a copy, we'll replace only the columns within the Raman band
  1647. nmRaman = nmArr[raman_nm_start_ix:raman_nm_end_ix] # nm values within the Raman band
  1648. raman_start_y = new_df.iloc[:,raman_nm_start_ix] # y-values from each row @ raman_nm_start
  1649. raman_end_y = new_df.iloc[:,raman_nm_end_ix] # y-values from each row @ raman_nm_end
  1650. raman_start_y.reset_index(inplace=True, drop=True) # don't forget this if you want to re-index rows from 0
  1651. raman_end_y.reset_index(inplace=True, drop=True) # don't forget this if you want to re-index rows from 0
  1652. x = np.array([nmRaman[0], nmRaman[-1]]) # 2 x-values (nm) defining the start & end of the Raman band
  1653. for rowCtr in range(new_df.shape[0]):
  1654. y = np.array([raman_start_y[rowCtr], raman_end_y[rowCtr]]) # x,y are 2 points that define the line thru the Raman band
  1655. ynew = np.interp(nmRaman, x, y) # interpolated y-values thru the Raman band
  1656. new_df.iloc[rowCtr,raman_nm_start_ix:raman_nm_end_ix] = ynew # replace the Raman spectrum with the interpolated values
  1657. # reassemble with original meta cols
  1658. spectra_RamanBlanked_df = pd.concat([metaCols,new_df], axis=1)
  1659. return spectra_RamanBlanked_df
  1660. # make sure dirOUT exists
  1661. import os
  1662. try:
  1663. if not os.path.exists(dirOUT): os.makedirs(dirOUT)
  1664. except:
  1665. pass
  1666. # adjust column types in place to reduce memory use
  1667. def adjustColumnTypes(df, firstLambdaColIx, nLambdaCols, cleanLambdaColHeaders=True, promoteLambdaColsToFloat64=False): # typically pass imageData in df
  1668. # promoteLambdaColsToFloat64=True if averaging to maintain highest precision, typically pass True in all avgSpectra scripts
  1669. if 'name' in df.columns: df['name'] = df['name'].astype('string') # force to str type in case names happen to be numeric
  1670. if 'classID' in df.columns: df['classID'] = df['classID'].astype('int8')
  1671. if 'T' in df.columns: df['T'] = df['T'].astype('uint8')
  1672. if 'X' in df.columns: df['X'] = df['X'].astype('uint16')
  1673. if 'Y' in df.columns: df['Y'] = df['Y'].astype('uint16')
  1674. if 'Z' in df.columns: df['Z'] = df['Z'].astype('uint8')
  1675. if 'unique4' in df.columns: df['unique4'] = df['unique4'].astype('uint16')
  1676. if 'intensity' in df.columns: df['intensity'] = df['intensity'].astype('float32')
  1677. # Convert lambda columns to float32 or 64
  1678. columns_to_convert = df.columns[firstLambdaColIx: firstLambdaColIx + nLambdaCols]
  1679. for col in columns_to_convert:
  1680. if promoteLambdaColsToFloat64:
  1681. df[col] = df[col].astype(np.float64)
  1682. else:
  1683. df[col] = df[col].astype(np.float32)
  1684. if cleanLambdaColHeaders:
  1685. df.rename(columns={col: float(col[2:]) if isinstance(col, str) and col.startswith('C#') else col for col in df.columns}, inplace=True) # strip all leading 'C#' substrings in col headers (check if col is already numeric to avoid AttributeError: 'float' object has no attribute 'startswith')
  1686. return
  1687. # restrict range of wavelengths by dropping wavelength columns from imageData_df in place
  1688. # imageData_df must be pre-processed with call to adjustColumnTypes()
  1689. # pass lambda_start = -1 to omit any modification of imageData_df
  1690. # returns the reduced nLambdas, and the truncated dataframe (all cols other than lambda cols are returned as is)
  1691. def restrictLambdaRange_OLD(imageData_df, lambda_start, lambda_end, firstLambdaCol, nLambdas):
  1692. if lambda_start < 0: return nLambdas, imageData_df
  1693. lambda_columns = imageData_df.columns[firstLambdaCol:firstLambdaCol+nLambdas] # str list of lambda headers
  1694. # Filter columns based on the numeric range
  1695. columns_to_keep = [col for col in imageData_df.columns if (col not in lambda_columns) or (lambda_start<=float(col)<=lambda_end)]
  1696. # Filter columns based on the numeric range
  1697. nColsRemoved = imageData_df.shape[1] - len(columns_to_keep)
  1698. nLambdas_new = nLambdas - nColsRemoved
  1699. filtered_df = imageData_df[columns_to_keep]
  1700. return nLambdas_new, filtered_df
  1701. # restrict range of wavelengths by dropping wavelength columns from imageData_df in place
  1702. # imageData_df must be pre-processed with call to adjustColumnTypes()
  1703. # pass lambda_start = -1 to omit any modification of imageData_df
  1704. # returns the restricted nLambdas, and the truncated dataframe (all cols other than lambda cols are returned as is)
  1705. def restrictLambdaRange(imageData_df, lambda_start, lambda_end, firstLambdaCol, nLambdas):
  1706. if lambda_start < 0:
  1707. return nLambdas, imageData_df
  1708. # Get the original lambda columns
  1709. lambda_columns = imageData_df.columns[firstLambdaCol:firstLambdaCol+nLambdas]
  1710. # Filter lambda columns based on the numeric range
  1711. lambda_columns_to_remove = [col for col in lambda_columns if not (lambda_start <= float(col) <= lambda_end)]
  1712. # Calculate how many lambda columns were removed
  1713. nLambdas_new = nLambdas - len(lambda_columns_to_remove)
  1714. # Create the filtered dataframe by dropping columns outside the range
  1715. filtered_df = imageData_df.drop(columns=lambda_columns_to_remove)
  1716. return nLambdas_new, filtered_df
  1717. def combine_arrays_with_varying_columns(coeffs):
  1718. num_rows = coeffs[0].shape[0] # Get the number of rows (consistent across arrays)
  1719. # Pre-allocate an empty array to store the combined data
  1720. combined_data = np.empty((num_rows, 0))
  1721. # Combine columns from each array
  1722. for arr in coeffs:
  1723. combined_data = np.hstack((combined_data, arr))
  1724. # Create a DataFrame from the combined data
  1725. df = pd.DataFrame(combined_data)
  1726. return df
  1727. def split_list(L, N): # for splitting wavelet_param_dicts into sublists by CPU
  1728. """
  1729. Splits a list L into N sublists as evenly as possible.
  1730. Args:
  1731. L: The list to split.
  1732. N: the number of sublists
  1733. Returns:
  1734. A list of N sublists.
  1735. """
  1736. n = len(L)
  1737. # Calculate the base sublist size and the number of extra elements
  1738. sublist_size = n // N
  1739. extra_elements = n % N
  1740. sublists = []
  1741. start = 0
  1742. for i in range(N):
  1743. # Adjust sublist size based on extra elements
  1744. end = start + sublist_size + (1 if i < extra_elements else 0)
  1745. sublists.append(L[start:end])
  1746. start = end
  1747. return sublists
  1748. # augment imageData by replicating names using_a, _b, etc suffixes
  1749. def augXdf(df, X):
  1750. # df is input dataframe, typically imageData, X = aug factor
  1751. if X <= 1:
  1752. uniqueNamesANDclassIDs = df.groupby('name')['classID'].first()
  1753. return df, uniqueNamesANDclassIDs
  1754. # Shuffle the modified DataFrame to randomize the assignment of subgroups
  1755. aug_df = df.sample(frac=1)
  1756. # Add a new column for the subgroup labels
  1757. aug_df['subgroup'] = aug_df.groupby('name').cumcount() % X
  1758. # Create the new name column with the subgroup suffixes
  1759. # aug_df['name'] = aug_df.apply(lambda row: f"{row['name']}_{chr(ord('a') + row['subgroup'])}", axis=1) # _a, _b, etc suffixes
  1760. aug_df['name'] = aug_df.apply(lambda row: f"{row['name']}_aug{str(row['subgroup']+1)}", axis=1) # _aug1, _aug2, etc suffixes, unlimited
  1761. # Drop the temporary 'subgroup' column
  1762. aug_df = aug_df.drop(columns='subgroup')
  1763. aug_df.sort_values(by=['classID', 'name'], inplace=True)
  1764. aug_df.reset_index(drop=True, inplace=True)
  1765. # you can check the new names and their classIDs like this: aug_df.groupby('name')['classID'].first()
  1766. uniqueNamesANDclassIDs = aug_df.groupby('name')['classID'].first()
  1767. return aug_df, uniqueNamesANDclassIDs
  1768. def augPow2df(df, target_power):
  1769. """
  1770. Augment dataframe so final number of unique names is 2^target_power
  1771. Args:
  1772. df: Input dataframe with 'name' and 'classID' columns
  1773. target_power: Power of 2 desired for final unique name count
  1774. """
  1775. # Get current number of unique names
  1776. current_unique = len(df['name'].unique())
  1777. target_unique = 2**target_power
  1778. if current_unique > target_unique:
  1779. return df, df.groupby('name')['classID'].first()
  1780. # Calculate required multiplication factor
  1781. X = int(np.ceil(target_unique / current_unique))
  1782. # Shuffle the DataFrame to randomize assignment
  1783. aug_df = df.sample(frac=1)
  1784. # Add subgroup column
  1785. aug_df['subgroup'] = aug_df.groupby('name').cumcount() % X
  1786. # Create new names with augmentation suffixes
  1787. aug_df['name'] = aug_df.apply(
  1788. lambda row: f"{row['name']}_aug{str(row['subgroup']+1)}",
  1789. axis=1
  1790. )
  1791. # Clean up and sort
  1792. aug_df = aug_df.drop(columns='subgroup')
  1793. aug_df.sort_values(by=['classID', 'name'], inplace=True)
  1794. aug_df.reset_index(drop=True, inplace=True)
  1795. # If we have more unique names than needed, randomly select names
  1796. if len(aug_df['name'].unique()) > target_unique:
  1797. keep_names = np.random.choice(
  1798. aug_df['name'].unique(),
  1799. size=target_unique,
  1800. replace=False
  1801. )
  1802. aug_df = aug_df[aug_df['name'].isin(keep_names)]
  1803. uniqueNamesANDclassIDs = aug_df.groupby('name')['classID'].first()
  1804. return aug_df, uniqueNamesANDclassIDs
  1805. def aug0df(df):
  1806. # df is input dataframe, typically imageData, special case for Mk6: each row is augmented to a unique instance
  1807. aug_df = df.copy()
  1808. aug_df['name'] = aug_df.groupby('name').cumcount().add(1).astype(str).radd(aug_df['name'] + '_')
  1809. return aug_df
  1810. def balancedTrainTestSplitWithAug(df, train_proportion, augX_train, augX_test=1):
  1811. # df must have 'name' and 'classID' cols, together with any number of feature cols
  1812. # returns an augmented balanced train-test split with 2 return df's ensuring that rootnames (after augmentation) appear only in one or the other set
  1813. # see also balancedTrainTestSplitWithAugFirst
  1814. import pandas as pd
  1815. from sklearn.model_selection import train_test_split
  1816. # Create a new DataFrame with unique names and their classID
  1817. name_class_df = df.groupby('name')['classID'].agg(lambda x:x.value_counts().index[0]).reset_index()
  1818. # Split unique names into train and test sets while preserving class balance
  1819. #train_names, test_names = train_test_split(name_class_df['name'], train_size=train_proportion, random_state=42, stratify=name_class_df['classID'])
  1820. train_names, test_names = train_test_split(name_class_df['name'], train_size=train_proportion, stratify=name_class_df['classID'])
  1821. # Create boolean masks for train and test sets based on names
  1822. train_mask = df['name'].isin(train_names)
  1823. test_mask = df['name'].isin(test_names)
  1824. # Split the DataFrame into train and test sets using the masks
  1825. df_train = df[train_mask]
  1826. df_test = df[test_mask]
  1827. df_train_aug, uniqueNamesANDclassIDs_train_aug = augXdf(df_train, augX_train) # data augmentation
  1828. df_test_aug, uniqueNamesANDclassIDs_test_aug = augXdf(df_test, augX_test) # data augmentation
  1829. df_train_aug.reset_index(inplace=True, drop=True) # drop means delete the old index column
  1830. df_test_aug.reset_index(inplace=True, drop=True) # drop means delete the old index column
  1831. return df_train_aug, df_test_aug, uniqueNamesANDclassIDs_train_aug, uniqueNamesANDclassIDs_test_aug
  1832. def balancedTrainTestSplitWithAugFirst(df, augX, train_proportion): # AUGMENTING FIRST IS LIKELY INCORRECT AND WILL RESULT IN DATA LEAKAGE TO TEST SET
  1833. # df must have 'name' and 'classID' cols, together with any number of feature cols
  1834. # returns an augmented balanced train-test split with 2 return df's NOT ensuring that rootnames (after augmentation) appear only in one or the other set ie. aug is done first and aug'd names are used as-is for the train-test split
  1835. # see also balancedTrainTestSplitWithAug
  1836. import pandas as pd
  1837. from sklearn.model_selection import train_test_split
  1838. # aug first:
  1839. df_aug,uniqueNamesANDclassIDs_df_aug = augXdf(df, augX)
  1840. # Create a new DataFrame with unique names and their most frequent (will all be the same here) classID
  1841. name_class_aug_df = df_aug.groupby('name')['classID'].agg(lambda x:x.value_counts().index[0]).reset_index()
  1842. # Split unique names into train and test sets while preserving class balance
  1843. train_names, test_names = train_test_split(name_class_aug_df['name'], train_size=train_proportion, random_state=42, stratify=name_class_aug_df['classID'])
  1844. # Create boolean masks for train and test sets based on names
  1845. train_mask = df_aug['name'].isin(train_names)
  1846. test_mask = df_aug['name'].isin(test_names)
  1847. # Split the DataFrame into train and test sets using the masks
  1848. df_train_aug = df_aug[train_mask]
  1849. df_test_aug = df_aug[test_mask]
  1850. df_train_aug, uniqueNamesANDclassIDs_train_aug = augXdf(df_train_aug, 1) # just to get uniqueNamesANDclassIDs_train_aug
  1851. df_test_aug, uniqueNamesANDclassIDs_test_aug = augXdf(df_test_aug, 1)
  1852. df_train_aug.reset_index(inplace=True, drop=True) # drop means delete the old index column
  1853. df_test_aug.reset_index(inplace=True, drop=True) # drop means delete the old index column
  1854. return df_train_aug, df_test_aug, uniqueNamesANDclassIDs_train_aug, uniqueNamesANDclassIDs_test_aug
  1855. def averageSpectraByName(spectra_df, lambdaColStartIx, nLambdaCols, sortByClassID=True):
  1856. # NAME-WISE AVERAGE OF ALL SPECTRA in spectra_df
  1857. # retains only name, classID and lambda cols
  1858. lambda_columns = spectra_df.columns[lambdaColStartIx:lambdaColStartIx+nLambdaCols]
  1859. lambda_columns = np.insert(lambda_columns, 0, 'classID')
  1860. # 2. Group, Aggregate, and Reset Index
  1861. df_avg = (
  1862. spectra_df.groupby('name')[lambda_columns] # Group by 'name'
  1863. .mean() # Calculate mean for each numeric column within groups
  1864. .reset_index() # Convert 'name' back into a regular column
  1865. )
  1866. df_avg['classID'] = df_avg['classID'].astype('Int64') # "mean" classID back to int
  1867. if sortByClassID:
  1868. df_avg.sort_values(by=['classID', 'name'], inplace=True)
  1869. df_avg.reset_index(drop=True, inplace=True)
  1870. new_lambdaColStartIx = 2 # because some cols were stripped this is the new lambdaColStartIx (nLambdaCols remains the same)
  1871. return df_avg, new_lambdaColStartIx
  1872. def get_column_indices(df, startChar='L'):
  1873. """Gets the 0-based indices of columns starting with 'L' in a DataFrame (ITRK can export kernel spectra by prefixing all lambda columns with 'L')
  1874. Args:
  1875. df: The pandas DataFrame.
  1876. Returns:
  1877. A list of column indices, # of columns starting with 'L'
  1878. """
  1879. l_columns = df.columns[df.columns.str.startswith(startChar)]
  1880. indices = [df.columns.get_loc(col) for col in l_columns]
  1881. N = len(indices)
  1882. if N == 0:
  1883. firstIndex = -1
  1884. else:
  1885. firstIndex = indices[0]
  1886. return firstIndex, N, indices
  1887. def get_numericalColumns(df):
  1888. """ Gets the 0-based indices of numerical columns
  1889. Use to detect the lambda columns in an imageDF
  1890. Args:
  1891. df: The pandas DataFrame.
  1892. Returns:
  1893. A list of column indices, # of numerical columns
  1894. """
  1895. # Function to check if a string is numeric
  1896. def is_numeric(column_name):
  1897. try:
  1898. float(column_name)
  1899. return True
  1900. except ValueError:
  1901. return False
  1902. # Find all numeric column names and the index of the first one
  1903. numeric_column_names = [col for col in df.columns if is_numeric(col)]
  1904. if numeric_column_names:
  1905. first_numeric_column_name = numeric_column_names[0]
  1906. first_numeric_column_index = df.columns.tolist().index(first_numeric_column_name)
  1907. # print(f"The list of numeric column names: {numeric_column_names}")
  1908. # print(f"The first numeric column name is: {first_numeric_column_name}")
  1909. # print(f"The zero-based index of this column is: {first_numeric_column_index}")
  1910. lambdas_np = np.array(numeric_column_names, dtype=float)
  1911. return first_numeric_column_index, len(lambdas_np), lambdas_np # firstLamvda, lambdas as strings, convert to np float array like this: )
  1912. else:
  1913. return -1, 0, np.empty((1), dtype=float)
  1914. def custom_growth(num_points, start, end, power):
  1915. """
  1916. Generates a monotonic custom growth curve using a power function.
  1917. Args:
  1918. start: Starting value of the sequence.
  1919. end: Ending value of the sequence.
  1920. power: Controls the steepness of the curve. Higher values make it steeper.
  1921. Must be greater than 0. power=1 results in a linear sequence.
  1922. num_points: The number of points to generate in the sequence.
  1923. Returns:
  1924. Array of output values corresponding to the custom growth curve.
  1925. start_value = 0.3
  1926. end_value = 10
  1927. power_factor = 2 # Higher values make the curve steeper
  1928. num_points = 20
  1929. y = custom_growth(start_value, end_value, power_factor, num_points)
  1930. # Visualization (with markers)
  1931. plt.figure(figsize=(10, 6))
  1932. plt.plot(y, marker='o', linestyle='-', label=f'Custom Growth (power={power_factor})')
  1933. plt.xlabel('Point Index')
  1934. plt.ylabel('Y Values')
  1935. plt.title('Custom Growth Curve (Power Function)')
  1936. plt.grid(True)
  1937. plt.legend()
  1938. plt.show()
  1939. """
  1940. if power <= 0:
  1941. raise ValueError("Power must be greater than 0")
  1942. x = np.linspace(0, 1, num_points) # Normalized x values
  1943. return start + (end - start) * x**power
  1944. # Plotting Functions
  1945. def plot_spectra(spectra):
  1946. """Plots a set of spectra."""
  1947. num_plots = len(spectra)
  1948. if num_plots == 1:
  1949. fig, ax = plt.subplots(1, num_plots, figsize=(7, 4)) # Changed to ax
  1950. ax.plot(spectra[0]) # .spectrum return the 1D spectral data
  1951. ax.set_title(f"Spectrum 1")
  1952. ax.set_xlabel("Wavelength")
  1953. #ax.set_ylabel("Amplitude")
  1954. ax.set_yticklabels([]) # This line removes the y-axis tick labels
  1955. else: # Multiple plots
  1956. fig, ax = plt.subplots(1, num_plots, figsize=(15, 4)) # Changed to ax
  1957. for i, spectrum in enumerate(spectra):
  1958. ax[i].plot(spectrum) # .spectrum return the 1D spectral data
  1959. #x[i].set_title(f"Spectrum {i+1}")
  1960. ax[i].set_title(spectrum) # SpectrumData class returns a custom string when referring to a class instance
  1961. ax[i].set_xlabel("Wavelength")
  1962. #ax[i].set_ylabel("Amplitude")
  1963. ax[i].set_yticklabels([]) # This line removes the y-axis tick labels
  1964. #plt.show()
  1965. # save it to a file:
  1966. plt.close()
  1967. ### MP version of process_spectra
  1968. import multiprocessing as mp
  1969. from functools import partial
  1970. def process_spectra_np_MP_chunk(chunk_with_index, wavelet_name, compression, scales, convertTofloat32): # although it would make sense to put this function inside process_spectra_np_MP this does not work with MP because of a pickling error: keep it separate here
  1971. chunk, start_index = chunk_with_index
  1972. chunk_scalograms = []
  1973. for i, spectrum in enumerate(chunk):
  1974. coeffs, _ = pywt.cwt(spectrum, scales, wavelet_name, sampling_period=1.0)
  1975. coeffs = CustomWavelet.compress_complex_array(coeffs=coeffs, power_factor=compression)
  1976. if convertTofloat32:
  1977. if np.iscomplexobj(coeffs):
  1978. coeffs = coeffs.astype(np.complex64)
  1979. else:
  1980. coeffs = coeffs.astype(np.float32)
  1981. # Separate real and imaginary parts if complex
  1982. # if np.iscomplexobj(coeffs):
  1983. # coeffs = np.stack((coeffs.real, coeffs.imag), axis=-1)
  1984. # else:
  1985. # coeffs = coeffs[..., np.newaxis]
  1986. chunk_scalograms.append((start_index + i, coeffs))
  1987. return chunk_scalograms
  1988. def process_spectra_np_MP(spectra_np, wavelet_name, compression, scales, convertTofloat32=False, maxCPUs=-1, verbose=0): # verbose maybe later
  1989. """ Computes CWT scalograms for a set of spectra. Returns a 4D array (N,H,W,C)
  1990. May need to limit max CPUs because of memory
  1991. """
  1992. num_cores = mp.cpu_count()
  1993. if maxCPUs > 0: num_cores = min(maxCPUs,num_cores)
  1994. if verbose > 0: printSB(f'process_spectra_np_MP is spawning {num_cores} processes')
  1995. total_rows = spectra_np.shape[0]
  1996. chunk_size = max(1, total_rows // num_cores)
  1997. chunks = [spectra_np[i:i+chunk_size] for i in range(0, total_rows, chunk_size)]
  1998. chunks_with_index = [(chunk, i*chunk_size) for i, chunk in enumerate(chunks)] # chunks_with_index is used to ensure that chunks are arranged in the original order of the input array
  1999. process_chunk_partial = partial(process_spectra_np_MP_chunk, wavelet_name=wavelet_name, compression=compression, scales=scales, convertTofloat32=convertTofloat32)
  2000. with mp.Pool(processes=num_cores) as pool:
  2001. results = pool.map(process_chunk_partial, chunks_with_index)
  2002. flat_results = [item for sublist in results for item in sublist]
  2003. sorted_results = sorted(flat_results, key=lambda x: x[0])
  2004. scalograms = [item[1] for item in sorted_results]
  2005. return np.array(scalograms)
  2006. ### END MP version of process_spectra
  2007. def process_spectra_np(spectra_np, wavelet_name, compression, scales, convertTofloat32=False):
  2008. """Computes CWT scalograms for a set of spectra. 1 scalogram for each row in spectra_np"""
  2009. scalograms = []
  2010. for i in range(spectra_np.shape[0]):
  2011. spectrum = spectra_np[i, :]
  2012. coeffs, freqs = pywt.cwt(spectrum, scales, wavelet_name, sampling_period=1.0)
  2013. coeffs = CustomWavelet.compress_complex_array(coeffs=coeffs, power_factor=compression) # compress dynamic range of coeffs
  2014. if convertTofloat32: # save memory
  2015. if np.iscomplexobj(coeffs): # complex
  2016. coeffs = coeffs.astype(np.complex64) # 32-bit single precision real and imag components = 64 bits total
  2017. else: # real
  2018. coeffs = coeffs.astype(np.float32) # 32-bit single precision real
  2019. scalograms.append(coeffs)
  2020. return np.array(scalograms)
  2021. def spectraToScalograms_2D(X, CWT_scales_list, CWT_type, normalizeWavelets=False, convertTofloat32=True):
  2022. # computes a 2D scalogram for each row in X which contains a spectrum. The 2D scalogram is flattened to a 1D vector (and complex scalograms have their real and complex flattened 1D vectors concatenated)
  2023. # final result returned as a 2D np array of N (=# of spectra/subjects/instances) rows of 1D vectors that are flattened scalograms
  2024. # Iterate through rows of X
  2025. scalograms = []
  2026. for index, row in enumerate(X):
  2027. coef, freqs = pywt.cwt(row, CWT_scales_list, CWT_type, sampling_period=1.0)
  2028. coef = CustomWavelet.compress_complex_array(coef) # compressed dynamic range of magnitudes
  2029. if convertTofloat32: # save memory
  2030. if np.iscomplexobj(coef): # complex
  2031. coef = coef.astype(np.complex64) # 32-bit single precision real and imag components
  2032. else: # real
  2033. coef = coef.astype(np.float32) # 32-bit single precision real
  2034. scalograms.append(coef)
  2035. scalograms_np = np.array(scalograms) # 3D array (N,CWT_N_scales,nL) of real or complex values
  2036. #printSB('scalograms_np.shape1:',scalograms_np.shape)
  2037. scalograms_np = scalograms_np.reshape(scalograms_np.shape[0],-1) # reshape to 2D array (N,CWT_N_scales*nL) of real or complex values
  2038. #printSB('scalograms_np.shape2:',scalograms_np.shape)
  2039. if np.iscomplexobj(scalograms_np): # we must handle complex results by processing mag & phase separately
  2040. #printSB(CWT_type + ': scalograms_np is complex')
  2041. scalograms_np_mag = np.abs(scalograms_np) # (N,CWT_N_scales*nL) reals
  2042. #printSB('scalograms_np_mag.shape:',scalograms_np_mag.shape)
  2043. scalograms_np_phase = np.angle(scalograms_np) # (N,CWT_N_scales*nL) imgs
  2044. if normalizeWavelets:
  2045. row_maxes = np.max(scalograms_np_mag, axis=1) # Find row maximums, handling all-zero rows
  2046. row_maxes[row_maxes == 0] = 1 # Replace zeros with 1 to avoid division by zero
  2047. scalograms_np_mag = scalograms_np_mag / row_maxes[:, np.newaxis] # Normalize each row
  2048. scalograms_np_phase = (scalograms_np_phase + np.pi) / (2 * np.pi) # Rescale phase angles from -pi..pi to to 0..1 to match normalized magnitudes
  2049. scalograms_np_combined = np.hstack([scalograms_np_mag, scalograms_np_phase]) # concat mag & phase; what is the shape here? should be (N,CWT_N_scales*nL*2) float32 reals: YES
  2050. else: # real only
  2051. #printSB(CWT_type + ': scalograms_np is real')
  2052. if normalizeWavelets:
  2053. row_maxes = np.max(scalograms_np, axis=1) # Find row maximums, handling all-zero rows
  2054. row_maxes[row_maxes == 0] = 1 # Replace zeros with 1 to avoid division by zero
  2055. scalograms_np = scalograms_np / row_maxes[:, np.newaxis] # Normalize each row
  2056. scalograms_np_combined = scalograms_np # pass through (N,CWT_N_scales*nL)
  2057. #printSB('Returning from spectraToScalograms_2D: scalograms_np_combined.shape:',scalograms_np_combined.shape)
  2058. return scalograms_np_combined # always real
  2059. def spectraToScalograms_4D(X_in, CWT_scales_list, CWT_type, compression, normalizeScalograms=False, convertTofloat32=False, verbose=0, MP=True, lambdaStartIx=-1, lambdaEndIx=-1):
  2060. # X_in is a 2D np array of features (typically wavelength bins normalized to peak 1.0), each instance in a row
  2061. # computes a 2D scalogram for each row in X_in which contains a spectrum. The 2D scalogram is NOT flattened to a 1D vector instead a 2D array/image is returned for each row (subject) in X_in, with 1 (real) or 2 (complex, depending on wavelet type) channels
  2062. # normalizeScalograms: normalizes each channel (real +/- imag) of a scalogram to its max val (not norm columns as in Standard Scaler). This is to prevent mag diffs between Re & Im channels
  2063. # final result returned as a 4D np array shape (N,H,W,C) where N is instances/names (this dimension will mirror the rows of X_in), H is scalogram height in pixles, W is scalogram width, C is channels (1 real, 2 complex)
  2064. # pass verbose>0 to produce a mod verbose log of rows being processed eg verbose=10000 will print a msg every 10000 rows/spectra
  2065. # if you ever do eg: avgSpectrum0=np.mean(features0.values, axis=0) to compute a mean spectrum from a bunch of raw spectral rows, then you want a single scalogram from this mean spectrum make you reshape X_in like this: X_in=avgSpectrum0.reshape(1,avgSpectrum0.shape[0])
  2066. # rev.2024-09-15: corrected complex normalization and float32 conversion in process_spectra_np
  2067. # rev.2025-01-26: now normalizeScalograms=False by default, but make sure you pass peak-normalized spectra in X_in
  2068. # rev.2025-03-05: lambdaStartIx, lambdaEndIx INCLUSIVE 0-based indexes to extract a subset of lambdas for computing scalograms, to restrict analysis to a certain wavelength band
  2069. if (CWT_type is None) or (CWT_type==''):
  2070. # raise ValueError('spectraToScalograms_4D: CWT_type was not defined')
  2071. printSB('FATAL ERROR: spectraToScalograms_4D: CWT_type was not defined')
  2072. sys.exit()
  2073. if (compression is None) or (compression==0):
  2074. # raise ValueError('spectraToScalograms_4D: compression was not defined or is 0')
  2075. printSB('FATAL ERROR: spectraToScalograms_4D: compression was not defined or is 0')
  2076. sys.exit()
  2077. if lambdaStartIx > -1: X_in = X_in[:, lambdaStartIx:lambdaEndIx+1] # important to extract the lambda subset BEFORE scalograms calculation?
  2078. if MP: # MP version:
  2079. if verbose > 0: printSB(f'Starting process_spectra_np_MP to transform {len(X_in)} spectra into scalograms...')
  2080. X_cwt = process_spectra_np_MP(X_in, wavelet_name=CWT_type, compression=compression, scales=CWT_scales_list, convertTofloat32=convertTofloat32, verbose=verbose) # X_cwt contains all the scalograms, 1 per mean spectrum, shape (N, height, width)
  2081. if verbose > 0: printSB(' DONE')
  2082. else: # non-MP version:
  2083. if verbose > 0: printSB(f'Starting process_spectra_np to transform {len(X_in)} spectra into scalograms...')
  2084. X_cwt = process_spectra_np(X_in, wavelet_name=CWT_type, compression=compression, scales=CWT_scales_list, convertTofloat32=convertTofloat32) # X_cwt contains all the scalograms, 1 per mean spectrum, shape (N, height, width)
  2085. if verbose > 0: printSB(' DONE')
  2086. # some wavelets return complex data:We separate the real (X_train.real) and imaginary (X_train.imag) components into two separate arrays, then stack them along the last axis to create a 3D with shape (N, height, width) for both real and complex wavelets, where N is instances, H is scales, W is wavelength bins (4D tensor with extra real & im channels is created below)
  2087. waveletIsComplex = np.iscomplexobj(X_cwt)
  2088. # printSB(f'DEBUG: lambdaStartIx={lambdaStartIx}. lambdaEndIx={lambdaEndIx}')
  2089. if waveletIsComplex:
  2090. #printSB('spectraToScalograms_4D is processing wavelet: ' + CWT_type + ' (complex)...')
  2091. X_cwt_mag = np.abs(X_cwt)
  2092. X_cwt_phase = np.angle(X_cwt)
  2093. if normalizeScalograms:
  2094. max_values = np.max(np.abs(X_cwt_mag), axis=(1, 2)) # Compute max values for each image (along the 2nd and 3rd axes)
  2095. max_values_reshaped = max_values[:, np.newaxis, np.newaxis] # Reshape max_values to enable broadcasting
  2096. X_cwt_mag = X_cwt_mag / max_values_reshaped # Normalize each image (not column as in StandardScaler) by its max value
  2097. # Verify the normalization (optional)
  2098. # max_after_normalization = np.max(X_cwt_mag, axis=(1, 2))
  2099. # printSB("Max values after normalization:", max_after_normalization) # Should be all 1.0
  2100. X_cwt_phase = (X_cwt_phase + np.pi) / (2 * np.pi) # Rescale phase angles from -pi..pi to 0..1 to match normalized magnitudes
  2101. X_cwt_stacked = np.stack((X_cwt_mag, X_cwt_phase), axis=-1) # Shape: (N, height, width, 2 channels)
  2102. X_out = X_cwt_stacked # this is the new input type
  2103. else: # real wavelet coefs
  2104. #printSB('spectraToScalograms_4D is processing wavelet: ' + CWT_type + '...')
  2105. if normalizeScalograms:
  2106. max_values = np.max(np.abs(X_cwt), axis=(1, 2)) # Compute max values for each image (along the 2nd and 3rd axes)
  2107. max_values_reshaped = max_values[:, np.newaxis, np.newaxis] # Reshape max_values to enable broadcasting
  2108. X_cwt = X_cwt / max_values_reshaped # Normalize each image by its max value
  2109. X_out = np.expand_dims(X_cwt, axis=-1) # Reshape to include channel dimension for Conv2D, now shape (N,H,W,C)
  2110. # independently standardize the channels (scales of X_mag & X_phase may be very different):
  2111. #X_train, train_mean, train_std = standardize_channels(X_train) THIS WAS DONE ABOVE
  2112. if verbose > 0: printSB('spectraToScalograms_4D: X_in.shape:',X_in.shape,' X_out.shape:',X_out.shape) # debug
  2113. return X_out, waveletIsComplex # shape (N,H,W,C), always real
  2114. def spectraToScalograms_3Dcomplex(X_in, CWT_scales_list, CWT_type, compression, convertTofloat32=False, verbose=0, MP=True, lambdaStartIx=-1, lambdaEndIx=-1):
  2115. # X_in is a 2D np array of features (typically wavelength bins normalized to peak 1.0), each instance in a row
  2116. # computes a 2D scalogram for each row in X_in which contains a spectrum. The 2D scalogram is NOT flattened to a 1D vector instead a 2D array/image is returned for each row (subject) in X_in, with 1 (real) or 2 (complex, depending on wavelet type) channels
  2117. # like spectraToScalograms_4D except returns a 3D array shape (N,H,W) of real or complex numbers, depending on the wavelet
  2118. if (CWT_type is None) or (CWT_type==''):
  2119. raise ValueError('FATAL ERROR: spectraToScalograms_3Dcomplex: CWT_type was not defined')
  2120. if (compression is None) or (compression==0):
  2121. # raise ValueError('spectraToScalograms_4D: compression was not defined or is 0')
  2122. raise ValueError('FATAL ERROR: spectraToScalograms_3Dcomplex: compression was not defined or is 0')
  2123. if lambdaStartIx > -1: X_in = X_in[:, lambdaStartIx:lambdaEndIx+1] # important to extract the lambda subset BEFORE scalograms calculation?
  2124. if MP: # MP version:
  2125. if verbose > 0: printSB(f'Starting process_spectra_np_MP to transform {len(X_in)} spectra into scalograms...')
  2126. X_cwt = process_spectra_np_MP(X_in, wavelet_name=CWT_type, compression=compression, scales=CWT_scales_list, convertTofloat32=convertTofloat32, verbose=verbose) # X_cwt contains all the scalograms, 1 per mean spectrum, shape (N, height, width)
  2127. if verbose > 0: printSB(' DONE')
  2128. else: # non-MP version:
  2129. if verbose > 0: printSB(f'Starting process_spectra_np to transform {len(X_in)} spectra into scalograms...')
  2130. X_cwt = process_spectra_np(X_in, wavelet_name=CWT_type, compression=compression, scales=CWT_scales_list, convertTofloat32=convertTofloat32) # X_cwt contains all the scalograms, 1 per mean spectrum, shape (N, height, width)
  2131. if verbose > 0: printSB(' DONE')
  2132. # some wavelets return complex data:We separate the real (X_train.real) and imaginary (X_train.imag) components into two separate arrays, then stack them along the last axis to create a 3D with shape (N, height, width) for both real and complex wavelets, where N is instances, H is scales, W is wavelength bins (4D tensor with extra real & im channels is created below)
  2133. waveletIsComplex = np.iscomplexobj(X_cwt)
  2134. X_out = X_cwt
  2135. if verbose > 0: printSB('spectraToScalograms_3Dcomplex: X_in.shape:',X_in.shape,' X_out.shape:',X_out.shape) # debug
  2136. return X_out, waveletIsComplex # shape (N,H,W), real or complex numbers
  2137. def plot_scalograms(scalograms):
  2138. """Plots a set of scalograms."""
  2139. num_plots = len(scalograms)
  2140. if num_plots == 1:
  2141. fig, ax = plt.subplots(1, num_plots, figsize=(4, 4))
  2142. ax.imshow(np.abs(scalograms[0]), extent=[0, N_points-1, CWT_scales_end, CWT_scales_start], cmap='viridis', aspect='auto') # Adjust extent based on scales
  2143. ax.set_title(f"Scalogram 1")
  2144. ax.set_ylabel("Scale")
  2145. ax.set_xlabel("Wavelength (nm)")
  2146. else: # Multiple plots
  2147. fig, ax = plt.subplots(1, num_plots, figsize=(15, 4))
  2148. for i, scalogram in enumerate(scalograms):
  2149. ax[i].imshow(np.abs(scalogram), extent=[0, N_points-1, CWT_scales_end, CWT_scales_start], cmap='viridis', aspect='auto') # Adjust extent based on scales, we use np.abs() in case scalograms are complex
  2150. ax[i].set_title(f"Scalogram {i+1}")
  2151. ax[i].set_ylabel("Scale")
  2152. ax[i].set_xlabel("Wavelength (nm)")
  2153. #plt.show()
  2154. # save it to a file:
  2155. plt.close()
  2156. def standardize_channels(X, mean=None, std=None):
  2157. """Standardizes the channels of a 2D array of images.
  2158. Args:
  2159. X: A NumPy array with shape (num_samples, height, width, channels).
  2160. mean: (Optional) Precomputed mean for each channel.
  2161. std: (Optional) Precomputed standard deviation for each channel.
  2162. Returns:
  2163. The standardized array with the same shape as X, along with
  2164. the calculated mean and std if not provided.
  2165. Example usage:
  2166. X_train_standardized, train_mean, train_std = standardize_channels(X_train)
  2167. Use the SAME mean and std to standardize X_test
  2168. X_test_standardized, _, _ = standardize_channels(X_test, mean=train_mean, std=train_std)
  2169. """
  2170. num_channels = X.shape[-1]
  2171. if mean is None:
  2172. mean = np.mean(X, axis=(0, 1, 2)) # Calculate mean per channel
  2173. if std is None:
  2174. std = np.std(X, axis=(0, 1, 2)) # Calculate std per channel
  2175. for channel in range(num_channels):
  2176. X[:, :, :, channel] = (X[:, :, :, channel] - mean[channel]) / std[channel]
  2177. return X, mean, std
  2178. ### plot class0 vs class1 average scalograms (we do this one first else this is the one [most recent] that is displayed in SB)
  2179. def plotClassAvgSpectraAndScalograms(imageData, firstLambdaCol, nLambdas, normMode, D, CWT_scales_list, CWT_type, fBaseNoExt, outPDF='', pad=0.5):
  2180. # fBaseNoExt: csv input fname, for graphtitle
  2181. # fig.tight_layout(pad=pad): pad value may need adjustment
  2182. # class-wise avg spectra (or their derivatives)
  2183. classCol = imageData['classID']
  2184. lambdaCols = imageData.iloc[:,firstLambdaCol:firstLambdaCol+nLambdas]
  2185. # lambdas = imageData_avg.columns[firstLambdaCol:].to_numpy().astype(np.float64)
  2186. #lambdas = df_total_NONaug_avg.columns[firstLambdaCol_avg:firstLambdaCol_avg+nLambdas].to_numpy().astype(np.float64)
  2187. lambdas = imageData.columns[firstLambdaCol:firstLambdaCol+nLambdas].to_numpy().astype(np.float64)
  2188. tick_locations = np.arange(400, lambdas.max() + 50, 50) # Start at 400, end at max value + 50, with a step of 50
  2189. # Filter lambdaCols based on classCol=0 values
  2190. filtered_lambdaCols = lambdaCols[classCol.iloc[:] == 0]
  2191. # Calculate the mean across rows
  2192. class0avg = filtered_lambdaCols.mean(axis=0)
  2193. # Filter lambdaCols based on classCol=1 values
  2194. filtered_lambdaCols = lambdaCols[classCol.iloc[:] == 1]
  2195. # Calculate the mean across rows
  2196. class1avg = filtered_lambdaCols.mean(axis=0)
  2197. if normMode == 1:
  2198. class0avg /= class0avg.max() # normalize to 1.0
  2199. class1avg /= class1avg.max()
  2200. # derivatives?
  2201. if D==1: # 1st derivative
  2202. class0avg = np.pad(np.diff(class0avg), pad_width=(0,1), mode='edge') # pad to retain same num of elements else 1 fewer than original
  2203. class1avg = np.pad(np.diff(class1avg), pad_width=(0,1), mode='edge')
  2204. elif D==2: # 2nd derivative
  2205. class0avg = np.pad(np.diff(class0avg), pad_width=(0,1), mode='edge') # pad to retain same num of elements else 1 fewer than original
  2206. class0avg = np.pad(np.diff(class0avg), pad_width=(0,1), mode='edge')
  2207. class1avg = np.pad(np.diff(class1avg), pad_width=(0,1), mode='edge')
  2208. class1avg = np.pad(np.diff(class1avg), pad_width=(0,1), mode='edge')
  2209. ### 3-panel graph
  2210. # plot formats:
  2211. nSubplotRows = 1
  2212. nSubplotCols = 3
  2213. figHeight_inches = 5
  2214. figWidth_inches = 2.07*figHeight_inches # match aspect ratio of canvas for nice display
  2215. plotTitleFontSize = 12
  2216. axisLabelFontSize = 13
  2217. tickLabelFontSize = 11
  2218. statsStrFontSize = 10
  2219. nameLabelsFontSize = 7
  2220. fig, axs = plt.subplots(nSubplotRows,nSubplotCols,figsize=(figWidth_inches, figHeight_inches))
  2221. fig.tight_layout(pad=pad) # adjust spacing between subplots; https://www.geeksforgeeks.org/how-to-set-the-spacing-between-subplots-in-matplotlib-in-python/
  2222. ### panel 1: class-wise avg spectra (or their derivatives)
  2223. ax=plt.subplot(nSubplotRows, nSubplotCols, 1) # left panel
  2224. graphTitle = fBaseNoExt + '\nAveraged Spectra'
  2225. if D==0:
  2226. ylabel = 'Intensity'
  2227. elif D==1: # 1st derivative
  2228. graphTitle = graphTitle + ' (D1)'
  2229. ylabel = 'D1'
  2230. elif D==2: # 2nd derivative
  2231. graphTitle = graphTitle + ' (D2)'
  2232. ylabel = 'D2'
  2233. ax.set_title(graphTitle,size=plotTitleFontSize-1)
  2234. ax.set_xlabel('Wavelength (nm)', size= axisLabelFontSize)
  2235. if kNormalizeSpectralOverlay:
  2236. ax.set_ylabel('Normalized ' + ylabel, size= axisLabelFontSize)
  2237. else:
  2238. ax.set_ylabel(ylabel, size= axisLabelFontSize)
  2239. ax.tick_params(axis='both', labelsize=tickLabelFontSize, direction='out', length=10, bottom=True, left=True, width=2) # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.tick_params.html
  2240. #ax.set_xticks([0,1],['0 (n=' + str(score_df[score_df['classID'] == 0].shape[0]) + ')','1 (n=' + str(score_df[score_df['classID'] == 1].shape[0]) + ')']) # classID labels and n's
  2241. # Define the tick locations
  2242. # Get the numerical columns for plotting
  2243. ax.grid(axis='y', color='0.8', linewidth=1.0)
  2244. #ax.axhline(0, color='blue')
  2245. #ax.legend(['Class 0', 'Class 1'])
  2246. #lambdas = imageData_avg.columns[firstLambdaCol:].to_numpy().astype(np.float64)
  2247. #tick_locations = np.arange(400, lambdas.max() + 50, 50) # Start at 400, end at max value + 50, with a step of 50
  2248. # Set the tick locator
  2249. ax.set_xlim([tick_locations.min(), tick_locations.max()]) # Set limits to encompass ticks
  2250. ax.plot(lambdas, class0avg, color='g',linewidth=0.5)
  2251. ax.plot(lambdas, class1avg, color='r',linewidth=0.5)
  2252. # Add the legend AFTER plotting
  2253. ax.legend(['class0', 'class1'], loc='upper right', fontsize=11)
  2254. ### panel 2: mean scalogram
  2255. mean_scalogram, freqs = pywt.cwt((class0avg + class1avg) / 2, CWT_scales_list, CWT_type, sampling_period=1.0) # mean scalogram from both classes
  2256. mean_scalogram = CustomWavelet.compress_complex_array(mean_scalogram) # compressed dynamic range of magnitudes
  2257. ax=plt.subplot(nSubplotRows, nSubplotCols, 2) # middle panel
  2258. graphTitle = 'Mean scalogram (' + CWT_type
  2259. if D==0:
  2260. graphTitle = graphTitle + ')'
  2261. elif D==1: # 1st derivative
  2262. graphTitle = graphTitle + ', D1)'
  2263. elif D==2: # 2nd derivative
  2264. graphTitle = graphTitle + ', D2)'
  2265. ax.set_title(graphTitle,size=plotTitleFontSize)
  2266. ax.tick_params(axis='both', labelsize=tickLabelFontSize, direction='out', length=10, bottom=True, left=True, width=2) # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.tick_params.html
  2267. # ax.imshow(np.abs(class0scalogram), extent=[lambdas[0], lambdas[-1], CWT_scales_list[-1], CWT_scales_list[0]], cmap='viridis', aspect='auto') # Adjust extent based on scales
  2268. ax.imshow(np.abs(mean_scalogram), extent=[lambdas[0], lambdas[-1], len(CWT_scales_list)-1, 0], cmap='viridis', aspect='auto') # extent is set linearly to the # of scales: we re-label the ticks with the real non-linear values below
  2269. ax.set_ylabel("Scale (higher values=lower frequencies)", size= axisLabelFontSize-1)
  2270. ax.set_xlabel("Wavelength (nm)", size= axisLabelFontSize)
  2271. num_ticks = 10
  2272. y_min, y_max = 0, len(CWT_scales_list)-1
  2273. y_ticks_positions = np.linspace(y_min, y_max, num_ticks, dtype=int)
  2274. y_ticks_values = CWT_scales_list[y_ticks_positions]
  2275. # Format tick labels based on value
  2276. y_tick_labels = [f"{val:.1f}" if val < 10 else f"{val:.0f}" for val in y_ticks_values]
  2277. # Set y-ticks and labels with fontsize
  2278. ax.set_yticks(y_ticks_positions) # this sets the even tick spacing along the y-axis
  2279. ax.set_yticklabels(y_tick_labels,fontsize=tickLabelFontSize-1) # here we apply the corrrect numerical labels for the non-linear CWT_scales_list
  2280. # difference scalogram
  2281. class0scalogram, freqs = pywt.cwt(class0avg, CWT_scales_list, CWT_type, sampling_period=1.0)
  2282. class1scalogram, freqs = pywt.cwt(class1avg, CWT_scales_list, CWT_type, sampling_period=1.0)
  2283. class0scalogram = CustomWavelet.compress_complex_array(class0scalogram) # compressed dynamic range of magnitudes
  2284. class1scalogram = CustomWavelet.compress_complex_array(class1scalogram) # compressed dynamic range of magnitudes
  2285. # Calculate difference image
  2286. diff_image = np.abs(class1scalogram) - np.abs(class0scalogram)
  2287. mean_absolute_difference_per_pixel = np.mean(np.abs(diff_image)) / diff_image.size
  2288. #printSB('diff_image mean_absolute_difference_per_pixel:',mean_absolute_difference_per_pixel) # later could be use this to find the wavelet that generates the largest difference between class0 & 1?
  2289. # Create custom colormap (blue for negative, red for positive)
  2290. from matplotlib.colors import LinearSegmentedColormap
  2291. from mpl_toolkits.axes_grid1.anchored_artists import AnchoredSizeBar
  2292. import matplotlib.font_manager as fm
  2293. from matplotlib.transforms import Bbox
  2294. import matplotlib as mpl
  2295. colors = [(0, 0, 1), (1, 0, 0)] # Blue to Red
  2296. cm = LinearSegmentedColormap.from_list("BlueRed", colors, N=256)
  2297. cm = mpl.colormaps['coolwarm'] # canned LUTs, eg coolwarm, seismic, RdBu_r, bwr
  2298. ax=plt.subplot(nSubplotRows, nSubplotCols, 3) # right panel
  2299. graphTitle = 'Mean difference scalogram'
  2300. ax.set_title(graphTitle,size=plotTitleFontSize)
  2301. ax.tick_params(axis='both', labelsize=tickLabelFontSize, direction='out', length=10, bottom=True, left=True, width=2) # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.tick_params.html
  2302. im = ax.imshow(diff_image, extent=[lambdas[0], lambdas[-1], len(CWT_scales_list)-1, 0], cmap =cm, aspect='auto') # Adjust extent based on scales
  2303. ax.set_ylabel("Scale (higher values=lower frequencies)", size= axisLabelFontSize-1)
  2304. ax.set_xlabel("Wavelength (nm)", size= axisLabelFontSize)
  2305. # Add scale bar (adjusted for subplots)
  2306. # Get the position of the last subplot
  2307. pos = ax.get_position()
  2308. #cbar = plt.colorbar(im, ax=ax, label='Diff')
  2309. #cbar = plt.colorbar(im, ax=ax)
  2310. # Define the colorbar position and width (adjust 'width' as needed)
  2311. cax = fig.add_axes([pos.x1 + 0.01, pos.y0, 0.02, pos.height]) # The pos.x1 + 0.02 in fig.add_axes() places the colorbar slightly to the right of the last subplot. The 0.02 controls the width of the colorbar (you can adjust this to your preference). The pos.height ensures the colorbar has the same height as the last subplot.
  2312. cbar = plt.colorbar(im, cax=cax)
  2313. cbar.ax.tick_params(labelsize=8, width=0.75, pad=1) # Colorbar tick labels and width, pad=2 moves the labels closer to the bar
  2314. cbar.ax.set_ylabel(cbar.ax.get_ylabel(), fontsize=8) # Colorbar label (title)
  2315. cbar.outline.set_linewidth(0.75) # Colorbar frame linewidth
  2316. # Shorten colorbar ticks
  2317. for tick in cbar.ax.get_yticklines():
  2318. tick.set_markersize(3) # Adjust the markersize (length) of the ticks
  2319. # Set y-ticks and labels
  2320. ax.set_yticks(y_ticks_positions) # this sets the even tick spacing along the y-axis
  2321. ax.set_yticklabels(y_tick_labels,fontsize=tickLabelFontSize-1) # here we apply the corrrect numerical labels for the non-linear CWT_scales_list
  2322. if outPDF == '':
  2323. plt.show() # presumably running interactively in jupyterlab
  2324. else:
  2325. plt.savefig(outPDF, bbox_inches='tight')
  2326. printSB('Avg scalograms saved to: ' + outPDF)
  2327. plt.close() # clear for next plot else we have old annotations persisting
  2328. return lambdas
  2329. ### save average scalograms to avg_scalograms_outPDF (we do this one first else this is the one [most recent] that is displayed in SB)
  2330. def saveMeanAndDiffScalograms(class_col_np, featureCols_avg_np, lambdas, normalize, D, CWT_scales_list, CWT_type, outPDF):
  2331. # featureCols_avg_np: 2D np array of class-wise mean spectra (±augmentation, ±differentiation)
  2332. # class_col_np: 1D np vector of classIDs (can convert a df to an np array like this: class_col_df.to_numpy(copy=False).astype(int) )
  2333. # lambdas: np array of wavelengths for featureCols_avg_np matrix
  2334. # normalize: boolean, normalize mean spectra to 1.0 peak
  2335. # D: 0,1,2, derivative, just for adjusting plot labels, differentiation of featureCols_avg_np must be done prior to the call
  2336. kAvgSpectraLinewidth = 1.0
  2337. tick_min = (lambdas[0] // 50) * 50 # next lower multiple of 50
  2338. tick_max = math.ceil(lambdas[-1] / 50) * 50
  2339. tick_locations = np.arange(tick_min, tick_max+50, 50) # steps of 50
  2340. # Filter rows based on class0
  2341. mask = (class_col_np == 0)
  2342. filtered_rows = featureCols_avg_np[mask]
  2343. # Calculate the mean
  2344. class0avg = np.mean(filtered_rows, axis=0)
  2345. # Filter rows based on class1
  2346. mask = (class_col_np == 1)
  2347. filtered_rows = featureCols_avg_np[mask]
  2348. class1avg = np.mean(filtered_rows, axis=0)
  2349. if normalize:
  2350. class0avg /= class0avg.max() # normalize to 1.0
  2351. class1avg /= class1avg.max()
  2352. ### 3-panel graph: mean subject-wise spectra, mean scalogram, class-diff scalogram
  2353. # plot formats:
  2354. nSubplotRows = 1
  2355. nSubplotCols = 3
  2356. figHeight_inches = 5
  2357. figWidth_inches = 2.07*figHeight_inches # match aspect ratio of canvas for nice display
  2358. plotTitleFontSize = 12
  2359. axisLabelFontSize = 14
  2360. tickLabelFontSize = 12
  2361. statsStrFontSize = 10
  2362. nameLabelsFontSize = 7
  2363. fig, axs = plt.subplots(nSubplotRows,nSubplotCols,figsize=(figWidth_inches, figHeight_inches))
  2364. fig.tight_layout(pad=1.0) # adjust spacing between subplots; https://www.geeksforgeeks.org/how-to-set-the-spacing-between-subplots-in-matplotlib-in-python/
  2365. ### panel1: class-wise avg spectra (or their derivatives)
  2366. graphTitle = 'Averaged Spectra'
  2367. if D==0:
  2368. ylabel = 'Intensity'
  2369. elif D==1: # 1st derivative
  2370. graphTitle = graphTitle + ' (D1)'
  2371. ylabel = 'D1'
  2372. elif D==2: # 2nd derivative
  2373. graphTitle = graphTitle + ' (D2)'
  2374. ylabel = 'D2'
  2375. ax=plt.subplot(nSubplotRows, nSubplotCols, 1) # left panel
  2376. ax.set_title(graphTitle,size=plotTitleFontSize)
  2377. ax.set_xlabel('Wavelength (nm)', size= axisLabelFontSize)
  2378. if normalize:
  2379. ax.set_ylabel('Normalized ' + ylabel, size=axisLabelFontSize)
  2380. else:
  2381. ax.set_ylabel(ylabel, size=axisLabelFontSize)
  2382. ax.tick_params(axis='both', labelsize=tickLabelFontSize, direction='out', length=10, bottom=True, left=True, width=2) # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.tick_params.html
  2383. #ax.set_xticks([0,1],['0 (n=' + str(score_df[score_df['classID'] == 0].shape[0]) + ')','1 (n=' + str(score_df[score_df['classID'] == 1].shape[0]) + ')']) # classID labels and n's
  2384. # Define the tick locations
  2385. # Get the numerical columns for plotting
  2386. ax.grid(axis='y', color='0.8', linewidth=1.0)
  2387. #ax.axhline(0, color='blue')
  2388. #ax.legend(['Class 0', 'Class 1'])
  2389. #columns_to_plot = imageData_avg.columns[firstLambdaCol:].to_numpy().astype(np.float64)
  2390. #tick_locations = np.arange(400, columns_to_plot.max() + 50, 50) # Start at 400, end at max value + 50, with a step of 50
  2391. # Set the tick locator
  2392. ax.set_xlim([tick_locations.min(), tick_locations.max()]) # Set limits to encompass ticks
  2393. ax.plot(lambdas, class0avg, color='g',linewidth=kAvgSpectraLinewidth)
  2394. ax.plot(lambdas, class1avg, color='r',linewidth=kAvgSpectraLinewidth)
  2395. ### panel 2: mean scalogram
  2396. mean_scalogram, freqs = pywt.cwt((class0avg + class1avg) / 2, CWT_scales_list, CWT_type, sampling_period=1.0) # mean scalogram from both classes
  2397. mean_scalogram = CustomWavelet.compress_complex_array(mean_scalogram) # compressed dynamic range of magnitudes
  2398. ax=plt.subplot(nSubplotRows, nSubplotCols, 2) # middle panel
  2399. graphTitle = 'Mean scalogram (' + CWT_type
  2400. if D==0:
  2401. graphTitle = graphTitle + ')'
  2402. elif D==1: # 1st derivative
  2403. graphTitle = graphTitle + ', D1)'
  2404. elif D==2: # 2nd derivative
  2405. graphTitle = graphTitle + ', D2)'
  2406. ax.set_title(graphTitle,size=plotTitleFontSize)
  2407. ax.tick_params(axis='both', labelsize=tickLabelFontSize, direction='out', length=10, bottom=True, left=True, width=2) # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.tick_params.html
  2408. # ax.imshow(np.abs(class0scalogram), extent=[lambdas[0], lambdas[-1], CWT_scales_list[-1], CWT_scales_list[0]], cmap='viridis', aspect='auto') # Adjust extent based on scales
  2409. ax.imshow(np.abs(mean_scalogram), extent=[lambdas[0], lambdas[-1], len(CWT_scales_list)-1, 0], cmap='viridis', aspect='auto') # extent is set linearly to the # of scales: we re-label the ticks with the real non-linear values below
  2410. ax.set_ylabel("Scale (higher values=lower frequencies)", size= axisLabelFontSize-1)
  2411. ax.set_xlabel("Wavelength (nm)", size= axisLabelFontSize)
  2412. num_ticks = 10
  2413. y_min, y_max = 0, len(CWT_scales_list)-1
  2414. y_ticks_positions = np.linspace(y_min, y_max, num_ticks, dtype=int)
  2415. y_ticks_values = CWT_scales_list[y_ticks_positions]
  2416. # Format tick labels based on value
  2417. y_tick_labels = [f"{val:.1f}" if val < 10 else f"{val:.0f}" for val in y_ticks_values]
  2418. # Set y-ticks and labels with fontsize
  2419. ax.set_yticks(y_ticks_positions) # this sets the even tick spacing along the y-axis
  2420. ax.set_yticklabels(y_tick_labels,fontsize=tickLabelFontSize-1) # here we apply the corrrect numerical labels for the non-linear CWT_scales_list
  2421. # difference scalogram
  2422. class0scalogram, freqs = pywt.cwt(class0avg, CWT_scales_list, CWT_type, sampling_period=1.0)
  2423. class1scalogram, freqs = pywt.cwt(class1avg, CWT_scales_list, CWT_type, sampling_period=1.0)
  2424. class0scalogram = CustomWavelet.compress_complex_array(class0scalogram) # compressed dynamic range of magnitudes
  2425. class1scalogram = CustomWavelet.compress_complex_array(class1scalogram) # compressed dynamic range of magnitudes
  2426. # Calculate difference image
  2427. diff_image = np.abs(class1scalogram) - np.abs(class0scalogram)
  2428. #mean_absolute_difference_per_pixel = np.mean(np.abs(diff_image)) / diff_image.size
  2429. #printSB('diff_image mean_absolute_difference_per_pixel:',mean_absolute_difference_per_pixel) # later could be use this to find the wavelet that generates the largest difference between class0 & 1?
  2430. # Create custom colormap (blue for negative, red for positive)
  2431. from matplotlib.colors import LinearSegmentedColormap
  2432. from mpl_toolkits.axes_grid1.anchored_artists import AnchoredSizeBar
  2433. import matplotlib.font_manager as fm
  2434. from matplotlib.transforms import Bbox
  2435. import matplotlib as mpl
  2436. colors = [(0, 0, 1), (1, 0, 0)] # Blue to Red
  2437. cm = LinearSegmentedColormap.from_list("BlueRed", colors, N=256)
  2438. cm = mpl.colormaps['coolwarm'] # canned LUTs, eg coolwarm, seismic, RdBu_r, bwr
  2439. ax=plt.subplot(nSubplotRows, nSubplotCols, 3) # right panel
  2440. graphTitle = 'Difference scalogram'
  2441. ax.set_title(graphTitle,size=plotTitleFontSize)
  2442. ax.tick_params(axis='both', labelsize=tickLabelFontSize, direction='out', length=10, bottom=True, left=True, width=2) # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.tick_params.html
  2443. im = ax.imshow(diff_image, extent=[lambdas[0], lambdas[-1], len(CWT_scales_list)-1, 0], cmap =cm, aspect='auto') # Adjust extent based on scales
  2444. ax.set_ylabel("Scale (higher values=lower frequencies)", size= axisLabelFontSize-1)
  2445. ax.set_xlabel("Wavelength (nm)", size= axisLabelFontSize)
  2446. # Add colorbar bar (adjusted for subplots)
  2447. # Get the position of the last subplot
  2448. pos = ax.get_position()
  2449. #cbar = plt.colorbar(im, ax=ax, label='Diff')
  2450. #cbar = plt.colorbar(im, ax=ax)
  2451. # Define the colorbar position and width (adjust 'width' as needed)
  2452. cax = fig.add_axes([pos.x1 + 0.02, pos.y0, 0.02, pos.height]) # The pos.x1 + 0.02 in fig.add_axes() places the colorbar slightly to the right of the last subplot. The 0.02 controls the width of the colorbar (you can adjust this to your preference). The pos.height ensures the colorbar has the same height as the last subplot.
  2453. cbar = plt.colorbar(im, cax=cax)
  2454. cbar.ax.tick_params(labelsize=8, width=0.75, pad=-5) # Colorbar tick labels and width, pad=2 moves the labels closer to the bar
  2455. cbar.ax.set_ylabel(cbar.ax.get_ylabel(), fontsize=8) # Colorbar label (title)
  2456. cbar.outline.set_linewidth(0.75) # Colorbar frame linewidth
  2457. # Shorten colorbar ticks
  2458. for tick in cbar.ax.get_yticklines():
  2459. tick.set_markersize(3) # Adjust the markersize (length) of the ticks
  2460. # Set y-ticks and labels
  2461. ax.set_yticks(y_ticks_positions) # this sets the even tick spacing along the y-axis
  2462. ax.set_yticklabels(y_tick_labels,fontsize=tickLabelFontSize-1) # here we apply the corrrect numerical labels for the non-linear CWT_scales_list
  2463. if outPDF == '':
  2464. plt.show() # presumably running interactively in jupyterlab
  2465. else:
  2466. plt.savefig(outPDF, bbox_inches='tight')
  2467. printSB('Avg scalograms saved to: ' + outPDF)
  2468. plt.close() # clear for next plot else we have old annotations persisting
  2469. return
  2470. def generate2classNoisyGaussians(N, num_points, x0, x1, width, noise_level, kRand, amplitude=1.0, start=400, end=750, includePlot=True):
  2471. """
  2472. Generate 2 sets of noisy gaussians (total N instances) centered @ x0 (class0) and x1 (class1)
  2473. Set kRand to True to randomize labels
  2474. Example usage:
  2475. N = int(100) # Number of samples
  2476. nL = 32 # Original number of features
  2477. center0=550
  2478. center1=560
  2479. width=50
  2480. noise_level=0.1
  2481. start=400
  2482. end=750
  2483. amplitude=1.0
  2484. kRand = False # Set to True to randomize labels
  2485. lambdas, X, y, X0, X1, y0, y1 = generate2classNoisyGaussians(N, nL, center0, center1, width, noise_level, kRand, amplitude, start, end)
  2486. plt.figure(figsize=(10, 6))
  2487. plt.plot(lambdas, X0[0], label='class0 Gaussian')
  2488. plt.plot(lambdas, X1[0], label='class1 Gaussian')
  2489. plt.xlabel('lambdas')
  2490. plt.ylabel('Intensity')
  2491. plt.title('2-class Noisy Gaussian Data')
  2492. plt.legend()
  2493. plt.grid(True)
  2494. plt.show()
  2495. """
  2496. import matplotlib.pyplot as plt
  2497. import numpy as np
  2498. def generate_noisy_gaussians(N, num_points, center, width, noise_level, amplitude=1.0, start=400, end=750):
  2499. """
  2500. Generates N 1D noisy Gaussian arrays and returns them as a 2D numpy array together with 0 or 1 labels
  2501. Args:
  2502. start (int): The starting value of the x-range.
  2503. end (int): The ending value of the x-range.
  2504. center (int): The center of the Gaussian distribution.
  2505. width (int): The width (standard deviation) of the Gaussian distribution.
  2506. amplitude (float): The peak amplitude of the Gaussian distribution.
  2507. noise_level (float): The percentage of noise to add (between 0 and 1).
  2508. num_points (int): The number of points in each generated array.
  2509. N (int): The number of noisy Gaussian arrays to generate.
  2510. Returns:
  2511. lambdas
  2512. np.ndarray: A 2D numpy array containing N noisy Gaussian arrays.
  2513. class labels
  2514. """
  2515. x = np.linspace(start, end, num_points)
  2516. def gaussian(x, mu, sigma, amplitude):
  2517. return amplitude * np.exp(-((x - mu) / sigma) ** 2 / 2)
  2518. y = gaussian(x, center, width, amplitude)
  2519. noisy_gaussians = []
  2520. for _ in range(N):
  2521. noise = np.random.normal(0, noise_level, num_points)
  2522. y_noisy = np.clip(y + noise, 0, None) # avoid neg values, 'None' means no upper bound for clipping
  2523. noisy_gaussians.append(y_noisy)
  2524. return x, np.array(noisy_gaussians)
  2525. # Set a seed for reproducibility
  2526. #np.random.seed(42)
  2527. # Generate X with two sets of Gaussians
  2528. lambdas, X0 = generate_noisy_gaussians(N // 2, num_points, x0, width, noise_level, amplitude, start, end)
  2529. _, X1 = generate_noisy_gaussians(N // 2, num_points, x1, width, noise_level, amplitude, start, end)
  2530. X = np.concatenate((X0, X1), axis=0)
  2531. # generate clean gaussians for the plot overlay
  2532. num_points_clean = 200
  2533. lambdas_clean, X0_clean = generate_noisy_gaussians(1, num_points_clean, x0, width, 0, amplitude, start, end)
  2534. _, X1_clean = generate_noisy_gaussians(1, num_points_clean, x1, width, 0, amplitude, start, end)
  2535. # Generate initial y ie the 2 classes
  2536. y0 = np.zeros(N // 2, dtype=int)
  2537. y1 = np.ones(N // 2, dtype=int)
  2538. y_initial = np.concatenate((y0, y1), axis=0)
  2539. # Randomize labels if kRand is True
  2540. if kRand:
  2541. y = shuffle(y_initial)
  2542. else:
  2543. y = y_initial
  2544. if includePlot:
  2545. plt.figure(figsize=(13, 6))
  2546. # plot 1st noisy gaussians from each class
  2547. plt.plot(lambdas, X0[0], label='class0', color='g')
  2548. plt.plot(lambdas, X1[0], label='class1', color='r')
  2549. # clean base gaussians
  2550. plt.plot(lambdas_clean, X0_clean[0], label='class0 base', linestyle='--', dashes=(6, 7), color='g', linewidth=0.7)
  2551. plt.plot(lambdas_clean, X1_clean[0], label='class1 base', linestyle='--', dashes=(6, 7), color='r', linewidth=0.7)
  2552. plt.xlabel('Lambda')
  2553. plt.ylabel('Intensity')
  2554. plt.title('Two-class Noisy Gaussians (1st from each class plotted)')
  2555. plt.legend()
  2556. plt.grid(True)
  2557. #plt.show()
  2558. else:
  2559. plt = None
  2560. return lambdas, X, y, X0, X1, y0, y1, plt
  2561. def generate2classGaussianImageData(nRowsPerSubject, N, nL, x0, x1, width, noise_level, outputCSV='', kRand=False, amplitude=1.0, start=400, end=750):
  2562. """
  2563. Generate a 2-class multirow synthetic gaussian imageData df for testing
  2564. nRowsPerSubject is # rows/subject
  2565. If outputCSV != '' writes the df to a csv as well
  2566. Example usage:
  2567. nRowsPerSubject = int(1e2)
  2568. N = 5+5 # total number of unique names split among 2 classes
  2569. nL = 32
  2570. x0 = 550 # center wavelength for class0
  2571. x1 = 560 # center wavelength for class1
  2572. width = 50
  2573. noise_level = 0.05
  2574. kRand = False
  2575. outputCSV = '/Users/pstys/Documents/imageDataGaussians_df.csv'
  2576. imageDataGaussians_df, plt = generate2classGaussianImageData(nRows, nSubjects, nL, x0, x1, width, noise_level, outputCSV, kRand)
  2577. plt.show
  2578. """
  2579. import pandas as pd
  2580. import numpy as np
  2581. def generate_name_array(N, nSubjects, prefix): # prefix like 'X0_'
  2582. """
  2583. Generates a 1D array of N strings with format 'X0_xxx'
  2584. Args:
  2585. N: Total number of strings to generate.
  2586. nSubjects: Number of groups to divide the strings into.
  2587. Returns:
  2588. A 1D numpy array of strings.
  2589. """
  2590. strings = np.empty(shape=N, dtype=object)
  2591. group_size = N // nSubjects
  2592. for i in range(N):
  2593. group_index = i // group_size
  2594. strings[i] = prefix + f"{group_index:03d}" # Pad group index with zeros
  2595. return strings
  2596. lambdas, X, y, X0, X1, y0, y1, plt = generate2classNoisyGaussians(nRowsPerSubject * N, nL, x0, x1, width, noise_level, kRand, amplitude, start, end)
  2597. # dummy cols with simulated Orange headers for import
  2598. dummy_data = np.full(nRowsPerSubject * N // 2, 100)
  2599. intensity_df = pd.DataFrame({'C#intensity': dummy_data})
  2600. X_df = pd.DataFrame({'C#X': dummy_data})
  2601. Y_df = pd.DataFrame({'C#Y': dummy_data})
  2602. Z_df = pd.DataFrame({'C#Z': dummy_data})
  2603. unique4_df = pd.DataFrame({'S#unique4': dummy_data})
  2604. # lambda col names
  2605. lambdas_col_names = np.array(["L" + format(x, "003.1f") for x in lambdas]) # like 'L411.3' etc
  2606. # generate the class0 sub-df
  2607. class0names = generate_name_array(nRowsPerSubject * N // 2, nSubjects // 2, 'X0_') # like 'X0_000', 'X0_001', etc
  2608. # Create a dictionary to map column names to NumPy arrays
  2609. class0data = {'mS#name': class0names, 'cD#classID': y0}
  2610. class0df = pd.DataFrame(class0data)
  2611. spectra0df = pd.DataFrame(X0, columns=lambdas_col_names)
  2612. # complete class0 table
  2613. class0df = pd.concat([class0df,intensity_df,spectra0df,X_df,Y_df,Z_df,unique4_df],axis=1)
  2614. # generate the class1 sub-df
  2615. class1names = generate_name_array(nRowsPerSubject * N // 2, nSubjects // 2, 'X1_') # like 'X1_000', 'X1_001', etc
  2616. # Create a dictionary to map column names to NumPy arrays
  2617. class1data = {'mS#name': class1names, 'cD#classID': y1}
  2618. class1df = pd.DataFrame(class1data)
  2619. spectra1df = pd.DataFrame(X1, columns=lambdas_col_names)
  2620. # complete class0 table
  2621. class1df = pd.concat([class1df,intensity_df,spectra1df,X_df,Y_df,Z_df,unique4_df],axis=1)
  2622. imageDataGaussians_df = pd.concat([class0df,class1df],axis=0)
  2623. if outputCSV != '': imageDataGaussians_df.to_csv(outputCSV,index = False, mode='w') # append df data to line1, if any, omitting the index column
  2624. return imageDataGaussians_df, plt
  2625. def generate2classJitteredGaussianImageData(n_root_subjects_per_class = 5,
  2626. n_aug = 100, # number of "augmented" spectra (ie averaged subgroup of spectra) per root subject; all n_aug spectra will have the same center wavelength
  2627. nL = 32, # number of points per gaussin (wavelength bins per spectrum)
  2628. noise_level = 0.05,
  2629. width = 30, # FWHM
  2630. gauss_ampl = 1.0, # ampl of gaussians
  2631. center_wavelength_class0 = 550, # all class0 gaussians +/-jitter
  2632. center_wavelength_class1 = 555, # all class5 gaussians +/-jitter
  2633. center_wavelength_jitter_per_class = 5, # center wavelengths of n_root_subjects_per_class in each class will be jittered by this amount; all n_aug spectra per rootname will have the same jittered centered wavelength, but will differ only by noise
  2634. outputCSV='',
  2635. includePlot=True
  2636. ):
  2637. gauss_list=[]
  2638. name_list=[]
  2639. classID_list=[]
  2640. for n_root_subjects_per_class_ctr in range(n_root_subjects_per_class):
  2641. jitter0 = random.uniform(-center_wavelength_jitter_per_class/2 , center_wavelength_jitter_per_class/2)
  2642. jitter1 = random.uniform(-center_wavelength_jitter_per_class/2 , center_wavelength_jitter_per_class/2)
  2643. for n_aug_ctr in range(n_aug):
  2644. # generate a jittered pair of class0 and class1 gaussians. The class jitter will be the same for all n_aug spectra for this root/class, but they will differ by noise only
  2645. lambdas, _, _, X0, X1, y0, y1, _ = generate2classNoisyGaussians(N=2,
  2646. num_points=nL,
  2647. x0=center_wavelength_class0+jitter0,
  2648. x1=center_wavelength_class1+jitter1,
  2649. width=width,
  2650. noise_level=noise_level,
  2651. kRand=False,
  2652. amplitude=1.0,
  2653. start=400,
  2654. end=750,
  2655. includePlot=False)
  2656. name_list.append(f'class0_{n_root_subjects_per_class_ctr+1}')
  2657. classID_list.append(y0[0])
  2658. gauss_list.append(X0[0])
  2659. name_list.append(f'class1_{n_root_subjects_per_class_ctr+1}')
  2660. classID_list.append(y1[0])
  2661. gauss_list.append(X1[0])
  2662. # combine into a df
  2663. # Create a list of dictionaries
  2664. lambdas = round_to_N_sig(lambdas, N=3)
  2665. data = []
  2666. for name, classID, gauss in zip(name_list, classID_list, gauss_list):
  2667. row = {
  2668. 'mS#name': name,
  2669. 'cD#classID': classID,
  2670. 'C#intensity': 100, # dummy
  2671. }
  2672. # Add the gauss array values with lambda values as column names
  2673. for i, lambda_val in enumerate(lambdas):
  2674. row[f'L{lambda_val:g}'] = gauss[i]
  2675. row['C#X']=100 # dummy
  2676. row['C#Y']=100 # dummy
  2677. row['C#Z']=100 # dummy
  2678. row['S#unique4']=100 # dummy
  2679. data.append(row)
  2680. # Create the DataFrame
  2681. df = pd.DataFrame(data)
  2682. if outputCSV != '': df.to_csv(outputCSV,index = False, mode='w') # append df data to line1, if any, omitting the index column
  2683. if includePlot:
  2684. import itertools
  2685. # Get the lambda columns (excluding 'name' and 'classID')
  2686. #lambda_columns = [col.lstrip('L') for col in df.columns if col not in ['mS#name', 'cD#classID']]
  2687. lambda_columns = df.columns[3:3+len(lambdas)]
  2688. # Create a color map
  2689. color_map = {0: 'green', 1: 'red'}
  2690. # Create the plot
  2691. plt.figure(figsize=(12, 6))
  2692. max_rows = 2 # Specify the maximum number of rows you want to iterate over
  2693. for index, row in itertools.islice(df.iterrows(), max_rows):
  2694. color = color_map[row['cD#classID']]
  2695. # Convert lambda values to float and plot
  2696. x = np.array([float(col.lstrip('L')) for col in lambda_columns])
  2697. y = row[lambda_columns].values
  2698. plt.plot(x, y, color=color, alpha=0.5)
  2699. # Customize the plot
  2700. plt.xlabel('Lambda')
  2701. plt.ylabel('Feature Value')
  2702. plt.title('Sample Class0 vs Class1 Gaussians')
  2703. # Add a legend
  2704. plt.plot([], [], color='green', label='Class 0')
  2705. plt.plot([], [], color='red', label='Class 1')
  2706. plt.legend()
  2707. # Show the plot
  2708. plt.grid(True)
  2709. #plt.show()
  2710. return df, plt
  2711. else:
  2712. return df, None
  2713. import psutil
  2714. def get_ram_infoinGB():
  2715. mem = psutil.virtual_memory()
  2716. total_ram = mem.total / (1024**3)
  2717. available_ram = mem.available / (1024**3)
  2718. used_ram = mem.used / (1024**3)
  2719. percent_used = mem.percent
  2720. return total_ram, available_ram, used_ram, percent_used
  2721. def adaptive_multi_split(X_df, y_df, N, D=2, test_size=0.2):
  2722. # X_df has a 'name' and many features cols
  2723. # y_df has a 0/1 target labels col
  2724. # returns a list of train-test splits so that taken together each name appears at most N times in all test sets, and no less than N-D times
  2725. from collections import Counter
  2726. def check_difference(report_df, D):
  2727. """
  2728. Check if the difference between the smallest and greatest value in 'test_set_count' is <= D.
  2729. Parameters:
  2730. - report_df: DataFrame with 'test_set_count' column
  2731. - D: Difference threshold
  2732. Returns:
  2733. - True if difference between smallest and greatest value is <= D, else False
  2734. """
  2735. min_count = report_df['test_set_count'].min()
  2736. max_count = report_df['test_set_count'].max()
  2737. return (max_count - min_count) <= D
  2738. def multi_split_and_select_subset(X_df, y_df, N, test_size=0.2, random_state=None):
  2739. """
  2740. Performs multiple train_test_splits and selects a subset of test sets to ensure each 'name'
  2741. appears exactly N times in the combined test sets.
  2742. Args:
  2743. X_df: Features DataFrame with 'name' column.
  2744. y_df: Target variable DataFrame.
  2745. N: Exact number of times each 'name' should appear in the final combined test sets.
  2746. test_size: Proportion of data to include in each test split.
  2747. random_state: Seed for reproducibility.
  2748. Returns:
  2749. A list of tuples, where each tuple contains the train and test sets from the selected splits.
  2750. A DataFrame containing 'name' and the count of their appearances in the final combined test sets (which should all be N).
  2751. """
  2752. name_counts = X_df['name'].value_counts().to_dict()
  2753. name_test_counts = {name: 0 for name in name_counts}
  2754. all_splits = []
  2755. # Perform splits until we have enough data to select the desired subset
  2756. while any(count < N for count in name_test_counts.values()):
  2757. X_train, X_test, y_train, y_test = train_test_split(
  2758. X_df, y_df, test_size=test_size, random_state=random_state, stratify=y_df
  2759. )
  2760. all_splits.append((X_train, X_test, y_train, y_test))
  2761. # Update counts for names in this test set
  2762. for name in X_test['name']:
  2763. name_test_counts[name] += 1
  2764. # Select a subset of splits to achieve the exact N count for each name
  2765. selected_splits = []
  2766. final_name_counts = Counter()
  2767. for X_train, X_test, y_train, y_test in all_splits:
  2768. temp_counts = final_name_counts + Counter(X_test['name'])
  2769. if all(count <= N for count in temp_counts.values()):
  2770. selected_splits.append((X_train, X_test, y_train, y_test))
  2771. final_name_counts = temp_counts
  2772. report_df = pd.DataFrame(list(final_name_counts.items()), columns=['name', 'test_set_count'])
  2773. return selected_splits, report_df
  2774. nIters=0
  2775. while True:
  2776. # Code to be executed repeatedly
  2777. nIters += 1
  2778. selected_splits, report_df = multi_split_and_select_subset(X_df, y_df, N, test_size)
  2779. if check_difference(report_df, D): # Check the "until" condition
  2780. break # Exit the loop if the condition is met
  2781. printSB('nIters:',nIters)
  2782. return selected_splits, report_df
  2783. def RAND(kRANDiters, dimReductionMode, bestDict):
  2784. ########################### RANDOMIZE USING BEST HYPERPARAMS ###########################
  2785. # fetch the best estimator from the grid search
  2786. grid_search = bestDict['grid_search']
  2787. best_pipe = bestDict['best_pipe']
  2788. cv = bestDict['cv'] # cross-validation object
  2789. grid_search_best_params_ = bestDict['grid_search_best_params_']
  2790. #best_params = grid_search.best_params_
  2791. best_pipe.set_params(**grid_search_best_params_) # Set the best hyperparameters
  2792. # fetch the original input data
  2793. X_combined_df = bestDict['X_combined_df'] # name, raw input features, score
  2794. X_featuresOnly = X_combined_df.drop(columns=['name','score']) # features only for fit
  2795. nameCol = X_combined_df['name']
  2796. y_df = bestDict['y_df']
  2797. nameAndClassID_df = pd.concat([nameCol,y_df],axis=1)
  2798. nameAndClassID_df.columns = ['name','classID']
  2799. name_classID_features_df = pd.concat([nameAndClassID_df,X_featuresOnly],axis=1) # name, classID, all feature cols
  2800. unique_names_classIDs = name_classID_features_df.drop_duplicates(subset=['name', 'classID']).copy().iloc[:,0:2] # unique names and matching classID cols
  2801. y = y_df.to_numpy(copy=False)
  2802. printSB('\nStarting ' + str(kRANDiters) + ' RAND iterations...')
  2803. d_RANDlist = [] # list of dicts that we will average into a aggregate RAND result
  2804. for RANDctr in range(kRANDiters):
  2805. if (RANDctr>0) and ((RANDctr+1)%5 == 0): printSB(' RAND ' + str(RANDctr+1) + '...')
  2806. temp_RAND, randSuffix = randomizeImageData(name_classID_features_df, unique_names_classIDs, randflag=1, verbose=0) # classID-randomized name, classID, all feature cols
  2807. nameAndClassID_RAND = temp_RAND[['name', 'classID']]
  2808. X_featuresOnly_RAND = temp_RAND.drop(columns=['name','classID']) # features only for fit
  2809. y_RAND = temp_RAND['classID'].to_numpy(copy=False)
  2810. grid_search.fit(X_featuresOnly_RAND, y_RAND) # repeat the gridsearch on target-randomized sets
  2811. accuracy = grid_search.best_score_ # test_accuracy
  2812. best_pipe = grid_search.best_estimator_
  2813. best_pipe.set_params(**grid_search.best_params_) # Set the best hyperparameters
  2814. distances = best_pipe.decision_function(X_featuresOnly_RAND) # by calling best_pipe's decision_function, X_featuresOnly will flow thru the entire scale->PCA->LDA pipeline
  2815. distances_series = pd.Series(distances, name='score') # Convert the NumPy array to a Series for seamless appending
  2816. distances_df = pd.DataFrame(distances_series)
  2817. d_RAND = {}
  2818. d_RAND['nameAndClassID_RAND'] = nameAndClassID_RAND
  2819. d_RAND['distances_df'] = distances_df
  2820. d_RAND['accuracy'] = accuracy
  2821. d_RANDlist.append(d_RAND)
  2822. #printSB('RAND:', d_best_RAND['Pstr'], d_best_RAND['AUCstr'], d_best_RAND['CM_logbase10str'])
  2823. ### here we have several d_RANDs in the d_RANDlist: average the results into a new d_best_RAND dict
  2824. # average scores by name, then compute stats on these aggregate avg scores
  2825. nameColRAND = d_RANDlist[0]['nameAndClassID_RAND']['name']
  2826. name_and_distance_RAND = pd.concat([nameColRAND,d_RANDlist[0]['distances_df']], axis=1) # first one
  2827. distances_df_RANDconcat = name_and_distance_RAND
  2828. for j in range(1,len(d_RANDlist)):
  2829. nameColRAND = d_RANDlist[j]['nameAndClassID_RAND']['name']
  2830. distances = d_RANDlist[j]['distances_df']
  2831. name_and_distances_df_RAND = pd.concat([nameColRAND,distances], axis=1)
  2832. distances_df_RANDconcat = pd.concat([distances_df_RANDconcat,name_and_distances_df_RAND],axis=0) # append new rows
  2833. distances_df_RANDconcat.reset_index(inplace = True, drop = True)
  2834. # group all scores by name, then calculate the mean of these grouped scores
  2835. grouped = distances_df_RANDconcat.groupby('name')['score'].mean()
  2836. distances_df_RAND_avg = pd.DataFrame(grouped).reset_index()
  2837. # for some reason distances on RAND data often have a large range: do we want to scale these for comparison with non-RAND?
  2838. scaler = StandardScaler()
  2839. distances_df_RAND_avg['score'] = scaler.fit_transform(distances_df_RAND_avg[['score']]) # std scale only the 'score' col
  2840. # stats on distances_df_RAND_avg (now original classes need not be 0 or 1, the classID column was reset to 0 & 1 for simplicity)
  2841. class0 = distances_df_RAND_avg.loc[y == 0]['score']
  2842. class1 = distances_df_RAND_avg.loc[y == 1]['score']
  2843. _, pValue_RAND = stats.ttest_ind(class0, class1, equal_var = True, alternative='less') #run independent 2 sample T-Test
  2844. Pstr_RAND = formatPstring(pValue_RAND)
  2845. # AUC
  2846. y_pred_RAND = np.where(distances_df_RAND_avg['score'] > 0, 1, 0) # so convert it to a predicted 0 or 1 class (need not correspond to classPair), depending on <0 vs >0
  2847. # y_actual = names_classIDs_X_reduced_df['classID']
  2848. y_actual = y
  2849. AUC_RAND = metrics.roc_auc_score(y_actual, y_pred_RAND)
  2850. if AUC_RAND == 1.0:
  2851. AUCstr_RAND = 'AUC = 1.0'
  2852. else:
  2853. AUCstr_RAND = 'AUC = {:.2f}'.format(AUC_RAND)
  2854. # accuracy
  2855. accuracy_RAND = metrics.accuracy_score(y_actual, y_pred_RAND)
  2856. if accuracy_RAND == 1.0:
  2857. accuracyStr_RAND = 'accuracy=1.0'
  2858. else:
  2859. accuracyStr_RAND = 'accuracy={:.2f}'.format(accuracy_RAND)
  2860. # f1 score
  2861. f1_RAND = metrics.f1_score(y_actual, y_pred_RAND)
  2862. # composite metric
  2863. CM_RAND, CMstr_RAND = calcCM(pValue_RAND, AUC_RAND, accuracy_RAND, p_value_logbase=10) # Higher values of p_value_logbase emphasize AUC over P value; p_value_logbase=0 uses AUC only
  2864. # Merging the DataFrames based on the 'name' column
  2865. out_df_RAND_avg = nameAndClassID_df.merge(distances_df_RAND_avg[['name', 'score']], on='name', how='left')
  2866. d = {}
  2867. d['pValue_RAND'] = pValue_RAND
  2868. d['Pstr_RAND'] = Pstr_RAND
  2869. d['AUC_RAND'] = AUC_RAND
  2870. d['AUCstr_RAND'] = AUCstr_RAND
  2871. d['accuracy_RAND'] = accuracy_RAND
  2872. d['accuracyStr_RAND'] = accuracyStr_RAND
  2873. d['CM_RAND'] = CM_RAND
  2874. d['CMstr_RAND'] = CMstr_RAND
  2875. d['out_df_RAND_avg'] = out_df_RAND_avg
  2876. return d
  2877. # custom print that wraps text in tokens that SB detects allows it to pass through for printing when soft-suspend is on. Useful to suppress unwanted warning messages from deep within certain libraries
  2878. def printSB_OLD(*args):
  2879. # must match in SB:
  2880. kSoftSuspendToken_start = "|||SB"
  2881. #kSoftSuspendToken_end = "SB|||"
  2882. if not args:
  2883. print() # Print an empty line if no arguments are provided
  2884. return
  2885. modified_args = list(args) # Convert tuple to list for easy modification
  2886. # Modify the first argument
  2887. modified_args[0] = f"{kSoftSuspendToken_start}{modified_args[0]}"
  2888. # Modify the last argument
  2889. #modified_args[-1] = f"{modified_args[-1]}{kSoftSuspendToken_end}"
  2890. # Print all arguments
  2891. print(*modified_args)
  2892. def printSB(*args, **kwargs):
  2893. kSoftSuspendToken_start = "|||SB"
  2894. #kSoftSuspendToken_end = "SB|||"
  2895. end = kwargs.get('end', '')
  2896. # Handle the 'sep' parameter if provided in kwargs
  2897. sep = kwargs.get('sep', ' ')
  2898. # Convert all positional arguments to strings and join them with spaces
  2899. args_str = ' '.join(str(arg) for arg in args)
  2900. args_str = sep.join(str(arg) for arg in args)
  2901. # Add the end character(s)
  2902. result = args_str + end # can write this to disk
  2903. print(kSoftSuspendToken_start + result)
  2904. return
  2905. """
  2906. if not args:
  2907. print(**kwargs) # Print an empty line if no arguments are provided, respecting kwargs
  2908. # printStr = **kwargs
  2909. else:
  2910. modified_args = list(args) # Convert tuple to list for easy modification
  2911. modified_args[0] = f"{kSoftSuspendToken_start}{modified_args[0]}"
  2912. # Extract 'end' from kwargs if present, otherwise use default '\n'
  2913. end = kwargs.pop('end', '\n')
  2914. # Print all arguments, using the specified or default 'end'
  2915. print(*modified_args, end=end, **kwargs)
  2916. #if end == '\n': print(kSoftSuspendToken_end) # close bracket, this traps end=??? forms
  2917. """
  2918. def printARC(*args, **kwargs):
  2919. # if printARC_fOut is a valid path will also write the string(s) to disk
  2920. end = kwargs.get('end', '')
  2921. # Handle the 'sep' parameter if provided in kwargs
  2922. sep = kwargs.get('sep', ' ')
  2923. # Convert all positional arguments to strings and join them with spaces
  2924. args_str = ' '.join(str(arg) for arg in args)
  2925. args_str = sep.join(str(arg) for arg in args)
  2926. # Add the end character(s)
  2927. result = args_str + end # can write this to disk
  2928. print(result)
  2929. try:
  2930. if printARC_fOut != '':
  2931. # Append mode - adds to existing content
  2932. with open(printARC_fOut, 'a') as file:
  2933. file.write(result+'\n')
  2934. except NameError:
  2935. print(f"DEBUG: printARC_fOut ({printARC_fOut}) does not exist")
  2936. return
  2937. def twoComponentScatterplot(names_classIDs_X_reduced_df, classPair, lambdas, normalize, axisLabels, CWT_type, graph_outPDF, subjectEmphasisList=[''], clf=None, ax=None, extraTitleLine=''):
  2938. # names_classIDs_X_reduced_df is a >=2 col df of the 2 components eg PC1,PC2, etc. Can have more than 2 cols, only the 1st 2 are plotted
  2939. # eg axisLabels = ['PC1',PC2']
  2940. # if an SVC was done pass clf to plot the decision boundary
  2941. # pass an existing subplot in ax to add to a graph instead
  2942. #printSB('twoComponentScatterplot:names_classIDs_X_reduced_df:\n',names_classIDs_X_reduced_df)
  2943. ### 2-panel graph
  2944. axIsNone = (ax==None)
  2945. targets = 2
  2946. colors = ['g','r']
  2947. legend = ['class ' + str(classPair[0]), 'class ' + str(classPair[1])]
  2948. xLabel = axisLabels[0]
  2949. yLabel = axisLabels[1]
  2950. plotTitleFontSize = 14
  2951. axisLabelFontSize = 13
  2952. tickLabelFontSize = 11
  2953. statsStrFontSize = 12
  2954. nameLabelsFontSize = 7
  2955. legendFontSize = 12
  2956. dotplotMarkerSize = 40
  2957. # MANOVA P
  2958. # Filter data based on classID (previously reset to 0 and 1 regardless of actual) and select the first two numerical columns
  2959. group_0_data = names_classIDs_X_reduced_df[names_classIDs_X_reduced_df['classID'] == classPair[0]].iloc[:, 2:4].values
  2960. group_1_data = names_classIDs_X_reduced_df[names_classIDs_X_reduced_df['classID'] == classPair[1]].iloc[:, 2:4].values
  2961. # Convert to numpy arrays (if not already)
  2962. grp0 = np.array(group_0_data)
  2963. grp1 = np.array(group_1_data)
  2964. _,MANOVA_Pstr = stats_MANOVA_np(grp0,grp1)
  2965. if ax == None:
  2966. # plot formats:
  2967. nSubplotRows = 1
  2968. nSubplotCols = 2
  2969. figHeight_inches = 5
  2970. figWidth_inches = 2.07*figHeight_inches # match aspect ratio of canvas for nice display
  2971. axIndex = 1
  2972. fig, axs = plt.subplots(nSubplotRows,nSubplotCols,figsize=(figWidth_inches, figHeight_inches))
  2973. fig.tight_layout(pad=1.0) # adjust spacing between subplots; https://www.geeksforgeeks.org/how-to-set-the-spacing-between-subplots-in-matplotlib-in-python/
  2974. ### avg spectra
  2975. ax=plt.subplot(nSubplotRows, nSubplotCols, 1) # left panel
  2976. ax.set_title('Averaged Spectra',size=plotTitleFontSize)
  2977. ax.set_xlabel('Wavelength (nm)', size= axisLabelFontSize)
  2978. if kNormalizeSpectralOverlay:
  2979. ax.set_ylabel('Normalized Intensity', size= axisLabelFontSize)
  2980. else:
  2981. ax.set_ylabel('Intensity', size= axisLabelFontSize)
  2982. ax.tick_params(axis='both', labelsize=tickLabelFontSize, direction='out', length=10, bottom=True, left=True) # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.tick_params.html
  2983. #ax.set_xticks([0,1],['0 (n=' + str(score_df[score_df['classID'] == 0].shape[0]) + ')','1 (n=' + str(score_df[score_df['classID'] == 1].shape[0]) + ')']) # classID labels and n's
  2984. # Define the tick locations
  2985. # Get the numerical columns for plotting
  2986. ax.grid(axis='y', color='0.8', linewidth=1.0)
  2987. #ax.axhline(0, color='blue')
  2988. #ax.legend(['Class 0', 'Class 1'])
  2989. columns_to_plot = imageData_avg.columns[firstLambdaCol:].to_numpy().astype(np.float64)
  2990. tick_locations = np.arange(400, columns_to_plot.max() + 50, 50) # Start at 400, end at max value + 50, with a step of 50
  2991. # Set the tick locator
  2992. ax.set_xlim([tick_locations.min(), tick_locations.max()]) # Set limits to encompass ticks
  2993. # Iterate over each unique name in the DataFrame
  2994. for name in imageData_avg['name'].unique():
  2995. # Filter the DataFrame for the current name
  2996. name_data = imageData_avg[imageData_avg['name'] == name]
  2997. spectrum = name_data.iloc[0,firstLambdaCol:firstLambdaCol+nLambdas].to_numpy().astype(np.float64)
  2998. if normalize: spectrum /= spectrum.max()
  2999. classID = int(name_data['classID'].iloc[0])
  3000. linewidth=1.0 # default
  3001. if name in subjectEmphasisList: linewidth+=1.5 # plot with heavier stroke
  3002. ax.plot(lambdas, spectrum, color=colors[classID],linewidth=linewidth)
  3003. leg = ax.legend([legend[0], legend[1]],
  3004. loc='upper right',
  3005. fontsize=11,
  3006. facecolor='white',
  3007. edgecolor='lightgray', # Remove legend outline
  3008. handlelength=0,
  3009. handletextpad=0, # Remove colored lines/handles
  3010. labelcolor=[colors[0],colors[1]])
  3011. # this is the only way to suppress the tiny handles in front of the legend labels:
  3012. for line in leg.get_lines():
  3013. line.set_linewidth(0.0)
  3014. axIndex += 1
  3015. ax = plt.subplot(nSubplotRows, nSubplotCols, axIndex)
  3016. ### 2 component scatterplot
  3017. if extraTitleLine != '': extraTitleLine = '\n' + extraTitleLine
  3018. if clf == None:
  3019. graphTitle = 'First 2 components (' + CWT_type + ')' + extraTitleLine
  3020. ax.set_title(graphTitle, fontsize = plotTitleFontSize-1)
  3021. else:
  3022. graphTitle = 'First 2 components (' + CWT_type + ')\n(+nuSVC decision boundary)'
  3023. ax.set_title(graphTitle, fontsize = plotTitleFontSize-2) + extraTitleLine
  3024. ax.set_xlabel(xLabel, fontsize = axisLabelFontSize)
  3025. ax.set_ylabel(yLabel, fontsize = axisLabelFontSize)
  3026. ax.tick_params(axis='both', labelsize=tickLabelFontSize, direction='out', length=10, bottom=True, left=True) # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.tick_params.html
  3027. X_reduced_df = names_classIDs_X_reduced_df.iloc[:, 2:4] # 2 reduced components
  3028. X_reduced_np = X_reduced_df.values
  3029. for n in range(2): # classID's were reset to 0 & 1 for simplicity
  3030. # indicesToKeep = (imageData_avg_df['classID'] == n)
  3031. indicesToKeep = (names_classIDs_X_reduced_df['classID'] == n)
  3032. #printSB('indicesToKeep @ ' + str(n),'\n',indicesToKeep)
  3033. #printSB('X_reduced_df:\n',X_reduced_df)
  3034. color = colors[n]
  3035. ax.scatter(X_reduced_df.loc[indicesToKeep, xLabel]
  3036. , X_reduced_df.loc[indicesToKeep, yLabel]
  3037. , c = color
  3038. , s = dotplotMarkerSize
  3039. , alpha=1)
  3040. # marker emphasis
  3041. # for index, row in imageData_avg_df.iterrows():
  3042. for index, row in names_classIDs_X_reduced_df.iterrows():
  3043. if row['name'] in subjectEmphasisList: ax.scatter(X_reduced_df.iloc[index,0], X_reduced_df.iloc[index,1], s=110, c=colors[0] if row['classID'] == 0 else colors[1])
  3044. # Auto-adjust x and y axis limits
  3045. x_margin = (X_reduced_np[:, 0].max() - X_reduced_np[:, 0].min()) * 0.05 # 5% margin on each side
  3046. y_margin = (X_reduced_np[:, 1].max() - X_reduced_np[:, 1].min()) * 0.05
  3047. ax.set_xlim(X_reduced_np[:, 0].min() - x_margin, X_reduced_np[:, 0].max() + x_margin)
  3048. ax.set_ylim(X_reduced_np[:, 1].min() - y_margin, X_reduced_np[:, 1].max() + y_margin)
  3049. #ax.legend(legend,fontsize = legendFontSize)
  3050. # all the options are to make the box smaller
  3051. ax.legend(
  3052. legend,
  3053. fontsize = legendFontSize-1,
  3054. loc='upper left',
  3055. #bbox_to_anchor=(0, 1),
  3056. #bbox_transform=ax.transAxes,
  3057. markerscale=0.5, # Smaller marker (0.5 is half the default size)
  3058. handlelength=1.0, # Shorter connecting line
  3059. labelspacing=0.2, # Tighter vertical spacing
  3060. borderpad=0.2, # Tighter border padding
  3061. handletextpad=0.2 # Less padding between marker/line and text
  3062. )
  3063. if clf != None: # plot decision boundary
  3064. # Create a meshgrid to visualize the decision boundary
  3065. h = .02 # Step size in the mesh
  3066. xlim = ax.get_xlim()
  3067. ylim = ax.get_ylim()
  3068. # rand is
  3069. x_min, x_max = X_reduced_np[:, 0].min() - 1, X_reduced_np[:, 0].max() + 1
  3070. y_min, y_max = X_reduced_np[:, 1].min() - 1, X_reduced_np[:, 1].max() + 1
  3071. xx, yy = np.meshgrid(np.arange(x_min, x_max, h),np.arange(y_min, y_max, h))
  3072. # Predict the class labels for the meshgrid points
  3073. Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
  3074. Z = Z.reshape(xx.shape)
  3075. # Plot the decision boundary and data points
  3076. #ax.contourf(xx, yy, Z, cmap=plt.cm.coolwarm, alpha=0.3)
  3077. from matplotlib.colors import ListedColormap
  3078. cmap = ListedColormap(['g', 'r']) # Green for class 0, red for class 1
  3079. ax.contourf(xx, yy, Z, cmap=cmap, alpha=0.1)
  3080. # print stats
  3081. x_min, x_max = ax.get_xlim()
  3082. y_min, y_max = ax.get_ylim()
  3083. x_range = x_max - x_min
  3084. y_range = y_max - y_min
  3085. ax.annotate(text=MANOVA_Pstr, xy=(x_min+x_range/2, y_min+y_range*0.5), xycoords='data', textcoords='offset points',fontsize=12, horizontalalignment='center')
  3086. if kLabelDatapoints:
  3087. # xRange = plotDf[xLabel].max() - plotDf[xLabel].min()
  3088. # for i in range(len(imageData_avg_df)):
  3089. for i in range(len(names_classIDs_X_reduced_df)):
  3090. # classID = imageData_avg_df.iloc[i].at['classID']
  3091. # name = imageData_avg_df.iloc[i].at['name']
  3092. classID = names_classIDs_X_reduced_df.iloc[i].at['classID']
  3093. name = names_classIDs_X_reduced_df.iloc[i].at['name']
  3094. PCx = X_reduced_df.iloc[i,0]
  3095. PCy = X_reduced_df.iloc[i,1]
  3096. xytext_xoffset = 4
  3097. if name in subjectEmphasisList: xytext_xoffset += 2
  3098. ax.annotate(name, xy=(PCx, PCy), xycoords='data',xytext=(xytext_xoffset,-2), textcoords='offset points',fontsize=nameLabelsFontSize) # https://matplotlib.org/stable/users/explain/text/annotations.html
  3099. if axIsNone: # we only want to save if not part of an existing graph passed in ax
  3100. plt.savefig(graph_outPDF, bbox_inches='tight')
  3101. # printSB('\n' + xLabel + ' vs ' + yLabel +' subject means graph saved to: ' + graph_outPDF)
  3102. plt.close() # clear for next plot else we have old annotations persisting
  3103. def binarize01(y): # converts all values in y <= 0 to 0, otherwise 1, returns a copy
  3104. y01 = y.copy()
  3105. # convert to 0 v 1
  3106. y01[y01 <= 0] = 0
  3107. y01[y01 > 0] = 1
  3108. y01 = y01.astype(int)
  3109. return y01
  3110. def check_type(obj):
  3111. if isinstance(obj, np.ndarray):
  3112. return "NumPy Array"
  3113. elif isinstance(obj, pd.DataFrame):
  3114. return "pandas DataFrame"
  3115. elif isinstance(obj, pd.Series):
  3116. return "pandas Series"
  3117. elif isinstance(obj, list):
  3118. return "Python List"
  3119. elif isinstance(obj, dict):
  3120. return "Python Dictionary"
  3121. else:
  3122. return type(obj).__name__
  3123. def round_to_N_sig(x, N=2):
  3124. """
  3125. Round the input to N significant digits.
  3126. Parameters:
  3127. x : numpy array or scalar
  3128. The input value(s) to be rounded
  3129. N : int
  3130. The number of significant digits to round to
  3131. Returns:
  3132. numpy array or scalar
  3133. The input rounded to N significant digits eg.
  3134. """
  3135. sign_x = np.sign(x)
  3136. x = np.abs(x) # we'll operate on +ve values only then adjust
  3137. with np.errstate(divide='ignore', invalid='ignore'):
  3138. # exponent = np.floor(np.log10(np.abs(x)))
  3139. exponent = np.floor(np.log10(x)) # x will now always be +ve
  3140. mantissa = x / (10 ** exponent)
  3141. rounded_mantissa = np.round(mantissa, N - 1)
  3142. r = rounded_mantissa * (10 ** exponent)
  3143. return sign_x * r
  3144. def round_to_N_sig_str(x, N=2):
  3145. """
  3146. as above but returns a string representation
  3147. """
  3148. r = round_to_N_sig(x=x, N=N)
  3149. return f'{r:g}'
  3150. def format_tick_labels(ax):
  3151. # formats numerical tick labels for subplots using the 'g' auto-format
  3152. # For y-axis
  3153. yticks = ax.get_yticks()
  3154. ax.yaxis.set_major_formatter(plt.FormatStrFormatter('%g'))
  3155. # For x-axis
  3156. xticks = ax.get_xticks()
  3157. ax.xaxis.set_major_formatter(plt.FormatStrFormatter('%g'))
  3158. ### dimensionality reduction using a CNN2D auto encoder
  3159. # Define the autoencoder model
  3160. def build_autoencoder(input_shape, n_components):
  3161. # Encoder
  3162. encoder_input = layers.Input(shape=input_shape)
  3163. x = layers.Conv2D(32, (3, 3), activation='relu', padding='same')(encoder_input)
  3164. x = layers.MaxPooling2D((2, 2), padding='same')(x)
  3165. x = layers.Conv2D(16, (3, 3), activation='relu', padding='same')(x)
  3166. x = layers.MaxPooling2D((2, 2), padding='same')(x)
  3167. x = layers.Conv2D(8, (3, 3), activation='relu', padding='same')(x)
  3168. x = layers.MaxPooling2D((2, 2), padding='same')(x)
  3169. x = layers.Flatten()(x)
  3170. encoded = layers.Dense(n_components, activation='linear')(x)
  3171. # Decoder
  3172. x = layers.Dense(8 * (input_shape[0] // 8) * (input_shape[1] // 8), activation='relu')(encoded)
  3173. x = layers.Reshape((input_shape[0] // 8, input_shape[1] // 8, 8))(x)
  3174. x = layers.Conv2D(8, (3, 3), activation='relu', padding='same')(x)
  3175. x = layers.UpSampling2D((2, 2))(x)
  3176. x = layers.Conv2D(16, (3, 3), activation='relu', padding='same')(x)
  3177. x = layers.UpSampling2D((2, 2))(x)
  3178. x = layers.Conv2D(32, (3, 3), activation='relu', padding='same')(x)
  3179. x = layers.UpSampling2D((2, 2))(x)
  3180. decoded = layers.Conv2D(input_shape[2], (3, 3), activation='sigmoid', padding='same')(x)
  3181. # Autoencoder
  3182. autoencoder = models.Model(encoder_input, decoded)
  3183. encoder = models.Model(encoder_input, encoded)
  3184. return autoencoder, encoder
  3185. # Function to perform dimensionality reduction
  3186. def reduce_dimensionality_2DCNN_AE(data, n_components, epochs=50, batch_size=32, verbose=1):
  3187. N, H, W, C = data.shape
  3188. input_shape = (H, W, C)
  3189. # Build and compile the autoencoder
  3190. autoencoder, encoder = build_autoencoder(input_shape, n_components)
  3191. autoencoder.compile(optimizer='adam', loss='mse')
  3192. # Split the data into training and validation sets
  3193. X_train, X_val = train_test_split(data, test_size=0.2, random_state=42)
  3194. # Train the autoencoder
  3195. history = autoencoder.fit(X_train, X_train, epochs=epochs, batch_size=batch_size,
  3196. shuffle=True, validation_data=(X_val, X_val), verbose=verbose)
  3197. # Use the encoder to get the reduced representation
  3198. reduced_data = encoder.predict(data)
  3199. # Evaluate the model
  3200. reconstructed_data = autoencoder.predict(data)
  3201. mse = mean_squared_error(data.flatten(), reconstructed_data.flatten())
  3202. return reduced_data, mse, history
  3203. def estimatedTimeRemaining(start_time, nIters, totalIters):
  3204. # save start_time = datetime.now() at start of long operatino and pass here
  3205. if (nIters <= 0) or (nIters > totalIters): return '?'
  3206. current_time = datetime.now()
  3207. elapsed_time = current_time - start_time
  3208. elapsed_seconds = elapsed_time.total_seconds()
  3209. proportion_complete = nIters / totalIters
  3210. # total_seconds = elapsed_seconds / proportion_complete # estimated total seconds
  3211. total_seconds = (totalIters/nIters)*elapsed_seconds # estimated total run time
  3212. # seconds_remaining = (totalIters-nIters)*total_seconds/totalIters
  3213. seconds_remaining = total_seconds-elapsed_seconds
  3214. if seconds_remaining < 60: # < 1hr
  3215. return '<1 min'
  3216. elif seconds_remaining < 3600: # < 1hr
  3217. return f'{int(seconds_remaining/60+0.5)} min'
  3218. elif seconds_remaining < 3600*10: # < 10hr
  3219. return f'{seconds_remaining/3600:.1f} hrs'
  3220. else: # >10hr
  3221. return f'{seconds_remaining/3600:.0f} hrs'
  3222. def remove_proportional_baseline(S,B):
  3223. # S is 1D array containing unknown spectrum X + p*baseline spectrum B
  3224. # finds p and recovers X
  3225. def objective(p):
  3226. return np.sum((S - p * B) ** 2)
  3227. def find_optimal_proportion(S, B):
  3228. result = minimize( objective,
  3229. x0=0.5,
  3230. bounds=[(0, None)],
  3231. method='SLSQP', # https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
  3232. options={
  3233. 'ftol': 1e-30,
  3234. 'eps': 1e-30,
  3235. 'maxiter': 10000,
  3236. 'disp': False
  3237. }
  3238. )
  3239. return result.x[0]
  3240. optimal_proportion = find_optimal_proportion(S, B)
  3241. X_recovered = S - optimal_proportion * B
  3242. return X_recovered
  3243. def RandomForestClassifier_loo_grid(feature_matrix_np, target_np, n_jobs=-1, swarmplot=False, q=15):
  3244. # feature_matrix_np shape (N,P) where N is instances, P is predictors eg PC1, PC2, etc
  3245. # target_np: class labels eg 0, 1
  3246. # maybe need to pass n_jobs=1 if already in an MP enivronment?
  3247. # q: Number of instances above which we switch from LeaveOneOut to RepeatedStratifiedKFold cross-val
  3248. # returns instance_probas = class1 probability
  3249. # Define parameter grid
  3250. param_grid = {
  3251. 'n_estimators': [50, 100, 200],
  3252. 'max_depth': [None, 10, 20, 30],
  3253. 'min_samples_split': [2, 5, 10],
  3254. 'min_samples_leaf': [1, 2, 4],
  3255. 'max_features': ['sqrt', 'log2', None] # Remove 'auto', use valid options
  3256. }
  3257. # Choose cross-validation strategy for GridSearch
  3258. if len(target_np) < q:
  3259. cv_grid = LeaveOneOut()
  3260. cv_pred = LeaveOneOut()
  3261. else:
  3262. cv_grid = RepeatedStratifiedKFold(n_splits=5, n_repeats=5, random_state=42)
  3263. cv_pred = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
  3264. # Initialize components and perform GridSearch with LOO
  3265. base_clf = RandomForestClassifier(random_state=42)
  3266. # Initialize GridSearchCV with error handling
  3267. grid_search = GridSearchCV(
  3268. estimator=base_clf,
  3269. param_grid=param_grid,
  3270. scoring='accuracy',
  3271. cv=cv_grid,
  3272. n_jobs=n_jobs,
  3273. error_score=np.nan
  3274. )
  3275. # Fit and get predictions
  3276. with np.errstate(invalid='ignore'): # ignore failed fits
  3277. grid_search.fit(feature_matrix_np, target_np)
  3278. # Get cross-validated probabilities
  3279. cv_probas = cross_val_predict(
  3280. grid_search.best_estimator_,
  3281. feature_matrix_np,
  3282. target_np,
  3283. cv=cv_pred,
  3284. method='predict_proba'
  3285. )
  3286. instance_probas = cv_probas[:, 1] # class1 probas
  3287. predicted_classes = (instance_probas > 0.5).astype(int)
  3288. # predicted_accuracy = accuracy_score(target_np, predicted_classes) # More likely to be optimistically biased (overfit)
  3289. mean_accuracy = grid_search.best_score_ # Obtained from cross-validation during grid search • Averages the accuracy scores across all CV folds • Represents the mean performance across different train-test splits • Generally more realistic estimate of model performance
  3290. if swarmplot:
  3291. # Create swarm plot
  3292. plt.figure(figsize=(7, 4))
  3293. sns.swarmplot(x=target_np, y=instance_probas, hue=target_np, palette={0: 'green', 1: 'red'}, legend=False)
  3294. plt.axhline(y=0.5, color='gray', linestyle='--', alpha=0.5)
  3295. plt.xlabel('Class')
  3296. plt.ylabel('Predicted Probability')
  3297. plt.title(f'Predicted Probabilities by Class (mean accuracy {mean_accuracy:.2f})')
  3298. #plt.show()
  3299. return mean_accuracy, predicted_classes, instance_probas, grid_search.best_params_, plt
  3300. else:
  3301. return mean_accuracy, predicted_classes, instance_probas, grid_search.best_params_, None
  3302. def array_to_line_distances(X, w, b):
  3303. """Calculate signed Euclidean distances from points to decision boundary.
  3304. Points where (X·w + b) > 0 get positive distances (class 1 side)
  3305. Points where (X·w + b) < 0 get negative distances (class 0 side)
  3306. """
  3307. # Calculate the decision function values
  3308. decision_values = np.dot(X, w) + b
  3309. # Convert to distances by normalizing by the weight vector norm
  3310. distances = decision_values / np.linalg.norm(w)
  3311. # Optionally reverse signs if you want class 0 side to be positive:
  3312. # distances = -distances
  3313. return distances
  3314. """
  3315. # sample code for scatterplot and perpendiculars with distances:
  3316. def plot_with_perpendiculars(X, y, w, b, distances):
  3317. plt.figure(figsize=(8, 8)) # Square figure
  3318. # Plot points
  3319. plt.scatter(X[y == 0][:, 0], X[y == 0][:, 1],

stys_WTv5xFAD.py, under CC-BY-4.0 · at the source

Overview

Authors: Shigeki Tsutsui1,2, Anastasiia A Stepanchuk1, Julian P Stys1,3, Stefanie A G Black1,2, George W Templeton2, Russell Greiner4, Peter K Stys1,2
  1. Hotchkiss Brain Institute, Department of Clinical Neurosciences, Cumming School of Medicine, University of Calgary, Calgary, Alberta, Canada
  2. Amira Medical Technologies Inc., Calgary, Alberta, Canada
  3. NovaSoft Interactive, Calgary, Alberta, Canada
  4. Department of Computing Science, Alberta Machine Intelligence Institute, University of Alberta, Edmonton, Canada
Journal: Journal of Alzheimer's disease : JAD, volume 112, issue 2, pages 933-946
Dates: received 2 October 2025; accepted 19 April 2026; published online 3 June 2026; in print July 2026
Type: Research article · Language: English
License: CC BY-NC
Identifiers: DOI 10.1177/13872877261453512 · PMID 42231859 · PMCID PMC13334060 · OpenAlex W7163317707
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: histology / microscopy (modality), human (organism), mouse (organism), Alzheimer's / dementia (population), methods / tools (subfield)
Methods: Statistics, Smoothing, state filtering, decompositions, Machine learning, fMRI & imaging
Keywords: Alzheimer's disease, conformationally-sensitive amyloid probes, machine learning, peripheral blood mononuclear cells, spectral confocal microscopy, wavelet
MeSH: Alzheimer Disease*, Leukocytes, Mononuclear*, Machine Learning*, Amyloid beta-Peptides, Animals, Female, Humans, Mice, Mice, Transgenic, Microscopy, Confocal, Spectrometry, Fluorescence, Support Vector Machine (* major topic)
Topic: Alzheimer's disease research and treatments (Physiology, Medicine), according to OpenAlex
Funding: Krembil Foundation; W. Garfield Weston Foundation; Brain Canada/Alzheimer’s Society Research Program; Accelerating Innovations into CarE; Alzheimer&apos;s Drug Discovery Foundation
Citations: not cited yet (Europe PMC); 68 references in the paper

Abstract

Background: Alzheimer's disease (AD) is the most common cause of dementia whose prevalence is projected to increase significantly in the coming decades. The recent advent of disease modifying therapies is a welcome development; however, it is also now apparent that early treatment maximizes the benefits of these drugs. Therefore, it is important to develop reliable methods of disease detection, preferably from an easily accessible matrix such as blood.

Objective: To develop a method for detecting AD from circulating white blood cells using spectral confocal microscopy.

Methods: Using K114-stained wild type and 5xFAD transgenic mouse cortical sections as proof-of-principle, spectral imaging of K114 fluorescence coupled with a signal processing/machine learning pipeline (spectral wavelet decomposition, dimensionality reduction, support vector machine classifier) can reliably distinguish non-plaque background parenchyma in the two strains. We then performed immunoprecipitation of Aβ from peripheral blood mononuclear cells (PBMCs) obtained from non-neurological controls and histopathologically-proven AD cases. We spectrally imaged the immunobeads labeled with K114, then used similar machine learning methods to classify control versus AD samples.

Results: Normal-appearing non-plaque 5xFAD background was reliably distinguished from wild type mouse brain. We could also classify AD with a high degree of reliability (area under the receiver operating curve = 0.95, p = 6.1e-5) and predict neuropathological scores from these blood elements (R = 0.89).

Conclusions: Our spectral imaging method, together with automated machine learning analysis of spectral micrographs, using readily obtainable PBMCs from blood, represents a potentially useful approach for detection of AD in living subjects.

Reproduced under the paper's license (CC BY-NC), from the paper cited above.

Repository

Its files are read in the Code ↔ Paper reader above, with 4 matches between paragraphs and lines of code.

Zenodo 18217440

License: CC-BY-4.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Languages: Python (1)
Size: 1 file, 1 script
Software Heritage: not checked
Found in: the text, “Machine learning methods and statistics for spec”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Keras (1 file), Matplotlib (1 file), NumPy (1 file), pandas (1 file), PyWavelets (1 file), scikit-learn (1 file), SciPy (1 file), seaborn (1 file), statsmodels (1 file), TensorFlow (1 file), UMAP (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
1 file

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 1 script, each with its path and the digest of its content;
  • 4 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 2, 28 September 2026

  • Publisher: n/a → IOS Press

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 7 authors, 6 keywords, 12 MeSH terms, 5 funders, 65 references.

Cite

This paper

Tsutsui, S., Stepanchuk, A. A., Stys, J. P., Black, S. A. G., Templeton, G. W., Greiner, R., & Stys, P. K. (2026). Fluorescence spectroscopy and machine learning methods for detection of Alzheimer's disease from circulating white blood cells. Journal of Alzheimer's disease : JAD, 112(2), 933-946. https://doi.org/10.1177/13872877261453512

BibTeX

@article{tsutsui2026fluorescence,
author = {Tsutsui, Shigeki and Stepanchuk, Anastasiia A and Stys, Julian P and Black, Stefanie A G and Templeton, George W and Greiner, Russell and Stys, Peter K},
title = {{Fluorescence spectroscopy and machine learning methods for detection of Alzheimer's disease from circulating white blood cells}},
journal = {Journal of Alzheimer's disease : JAD},
year = {2026},
month = jun,
volume = {112},
number = {2},
pages = {933--946},
publisher = {IOS Press},
issn = {1387-2877},
doi = {10.1177/13872877261453512},
url = {https://doi.org/10.1177/13872877261453512},
pmid = {42231859},
pmcid = {PMC13334060}
}

RIS

TY - JOUR
AU - Tsutsui, Shigeki
AU - Stepanchuk, Anastasiia A
AU - Stys, Julian P
AU - Black, Stefanie A G
AU - Templeton, George W
AU - Greiner, Russell
AU - Stys, Peter K
TI - Fluorescence spectroscopy and machine learning methods for detection of Alzheimer's disease from circulating white blood cells
T2 - Journal of Alzheimer's disease : JAD
J2 - J Alzheimers Dis
PY - 2026
DA - 2026/06/03
VL - 112
IS - 2
SP - 933
EP - 946
SN - 1387-2877
PB - IOS Press
DO - 10.1177/13872877261453512
UR - https://doi.org/10.1177/13872877261453512
LA - en
ER -

CSL-JSON

{
"id": "10.1177/13872877261453512",
"type": "article-journal",
"title": "Fluorescence spectroscopy and machine learning methods for detection of Alzheimer's disease from circulating white blood cells",
"container-title": "Journal of Alzheimer's disease : JAD",
"author": [
{
"family": "Tsutsui",
"given": "Shigeki"
},
{
"family": "Stepanchuk",
"given": "Anastasiia A"
},
{
"family": "Stys",
"given": "Julian P"
},
{
"family": "Black",
"given": "Stefanie A G"
},
{
"family": "Templeton",
"given": "George W"
},
{
"family": "Greiner",
"given": "Russell"
},
{
"family": "Stys",
"given": "Peter K"
}
],
"container-title-short": "J Alzheimers Dis",
"volume": "112",
"issue": "2",
"page": "933-946",
"DOI": "10.1177/13872877261453512",
"PMID": "42231859",
"PMCID": "PMC13334060",
"ISSN": "1387-2877",
"publisher": "IOS Press",
"URL": "https://doi.org/10.1177/13872877261453512",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
3
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1038/s41592-026-03057-2 [code]
CREsted: modeling genomic and synthetic cell-type-specific enhancers across tissues and species.
Journal: Nature methods
In common: PyWavelets, Keras, UMAP, 8 other tools, methods / tools, mouse
[2] doi:10.1038/s41467-026-72057-9 [code]
Sex-specific behavioral feedback modulates sensorimotor processing and drives flexible social behavior.
Journal: Nature communications
In common: PyWavelets, Keras, UMAP, 8 other tools
[3] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: Keras, UMAP, TensorFlow, 7 other tools, mouse
[4] doi:10.1093/nar/gkag706 [code]
scDifformer: diffusion-based post-training for virtual cell modeling across large-scale single-cell data.
Journal: Nucleic acids research
In common: Keras, UMAP, TensorFlow, 7 other tools
[5] doi:10.1038/s41398-026-04081-8 [code]
Functional system-specific brain aging across the Alzheimer's disease continuum.
Journal: Translational psychiatry
In common: Keras, TensorFlow, statsmodels, 6 other tools, Alzheimer's / dementia, 1 reference
[6] doi:10.1093/jnen/nlaf152 [code]
Clinical and pathologic correlations of machine learning quantification of Aβ deposits across 3 brain regions of decedents with Alzheimer disease.
Journal: Journal of neuropathology and experimental neurology
In common: Keras, TensorFlow, scikit-learn, 4 other tools, Alzheimer's / dementia, 2 references
[7] doi:10.64898/2026.05.06.26352540 [code]
Generating synthetic tau-PET scans in Alzheimer’s disease from MRI, blood biomarkers and demographics with deep learning
Journal: medRxiv (preprint)
In common: Keras, TensorFlow, seaborn, 5 other tools, Alzheimer's / dementia, 1 reference
[8] doi:10.1039/d6ra03343a [code]
A benchmark dataset and interpretable deep learning framework for drug-induced developmental neurotoxicity prediction.
Journal: RSC advances
In common: Keras, UMAP, TensorFlow, 6 other tools, methods / tools
[9] doi:10.1038/s41593-026-02376-z [code]
A framework for comparative analysis of human and mouse cortical neuron dendrites in corresponding brain regions.
Journal: Nature neuroscience
In common: Keras, UMAP, statsmodels, 6 other tools, mouse
[10] doi:10.1016/j.xcrm.2026.102766 [code]
A longitudinal single-cell and spatial multiomic atlas of pediatric high-grade glioma.
Journal: Cell reports. Medicine
In common: Keras, UMAP, TensorFlow, 6 other tools

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.