dspin 1.4.2__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dspin
3
- Version: 1.4.2
3
+ Version: 1.5.0
4
4
  Summary: Regulatory network models from single-cell perturbation profiling
5
5
  Home-page: https://github.com/JialongJiang/DSPIN
6
6
  Author: Jialong Jiang
@@ -21,6 +21,10 @@ from typing import Tuple, List, Callable, Any, Dict
21
21
  import warnings
22
22
  from scipy.sparse import issparse
23
23
  from joblib import Parallel, delayed, parallel_backend
24
+ from sklearn.cluster import AgglomerativeClustering
25
+ import leidenalg as la
26
+ import igraph as ig
27
+ import networkx as nx
24
28
 
25
29
 
26
30
  def category_balance_number(
@@ -70,11 +74,6 @@ def category_balance_number(
70
74
  return sampling_number
71
75
 
72
76
 
73
- from sklearn.cluster import AgglomerativeClustering
74
- import leidenalg as la
75
- import igraph as ig
76
- import networkx as nx
77
-
78
77
  def summary_components(all_components: np.array,
79
78
  num_spin: int,
80
79
  summary_method: str = 'kmeans',
@@ -125,8 +124,6 @@ def summary_components(all_components: np.array,
125
124
  # Determine gene cluster assignments based on the maximum component values.
126
125
  gene_groups_ind.append(np.argmax(components_kmeans, axis=0) == ii)
127
126
 
128
-
129
-
130
127
  sc.set_figure_params(figsize=(8, 4))
131
128
 
132
129
  plt.subplot(1, 2, 1)
@@ -569,6 +566,7 @@ def para_moments_numpy(j_mat: np.array, h_vec: np.array) -> Tuple[np.array, np.a
569
566
 
570
567
  return corr_para, mean_para
571
568
 
569
+
572
570
  @numba.njit(fastmath=True, cache=True)
573
571
  def para_moments(j_mat: np.ndarray,
574
572
  h_vec: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
@@ -708,8 +706,7 @@ def pseudol_gradient(cur_j: np.ndarray,
708
706
  def samp_moments(j_mat: np.ndarray,
709
707
  h_vec: np.ndarray,
710
708
  sample_size: int,
711
- mixing_time: int,
712
- samp_gap: int) -> Tuple[np.ndarray, np.ndarray]:
709
+ mixing_time: int) -> Tuple[np.ndarray, np.ndarray]:
713
710
  """
714
711
  Sample moments for the Markov Chain Monte Carlo (MCMC).
715
712
 
@@ -723,8 +720,6 @@ def samp_moments(j_mat: np.ndarray,
723
720
  Size of the sample.
724
721
  mixing_time : int
725
722
  Mixing time for the MCMC.
726
- samp_gap : int
727
- Gap between samples.
728
723
 
729
724
  Returns
730
725
  -------
@@ -732,73 +727,241 @@ def samp_moments(j_mat: np.ndarray,
732
727
  The correlation parameter.
733
728
  The mean parameter.
734
729
  """
735
- per_batch = int(1e5)
730
+ sample_size = int(sample_size)
731
+ mixing_time = int(mixing_time)
732
+
736
733
  num_spin = j_mat.shape[0]
737
- rec_corr = np.zeros((num_spin, num_spin))
738
- rec_mean = np.zeros(num_spin)
739
- beta = 1
740
- batch_count = 1
741
- rec_sample = np.empty((num_spin, min(per_batch, int(sample_size))))
742
- cur_spin = (np.random.randint(0, 3, (num_spin, 1)) - 1).astype(np.float64)
743
- tot_sampling = int(
744
- mixing_time + sample_size * samp_gap - mixing_time % samp_gap)
734
+ rec_corr = np.zeros((num_spin, num_spin), dtype=np.float64)
735
+ rec_mean = np.zeros(num_spin, dtype=np.float64)
736
+ beta = 1.0
745
737
 
746
- rand_ind = np.random.randint(0, num_spin, tot_sampling)
747
- rand_flip = np.random.randint(0, 2, tot_sampling)
738
+ # Cast & make contiguous (Numba-safe)
739
+ j_mat = np.ascontiguousarray(j_mat.astype(np.float64))
740
+ h_vec = np.ascontiguousarray(h_vec.astype(np.float64)).reshape(-1)
741
+
742
+ # Spins in {-1,0,1}
743
+ cur_spin = np.sign(h_vec).astype(np.float64)
744
+
745
+ # Local field f = h + J @ s (update incrementally on changes)
746
+ f_vec = h_vec + j_mat.dot(cur_spin)
747
+
748
+ # Sampling schedule
749
+ tot_sampling = sample_size + mixing_time
750
+
751
+ # Pre-generate randomness (one uniform per site update)
748
752
  rand_prob = np.random.rand(tot_sampling)
749
753
 
750
- # Monte Carlo Sampling
754
+ # Lazy accumulation buffers
755
+ last_outer = np.zeros((num_spin, num_spin), dtype=np.float64) # current s s^T
756
+ have_last_outer = False
757
+ recording_started = False
758
+ weight = 0 # how many identical samples to add for current last_outer
759
+
760
+ # MCMC with sweep updates + heat-bath conditionals
751
761
  for ii in range(tot_sampling):
752
- cur_ind = rand_ind[ii]
753
- j_sub = j_mat[cur_ind, :]
754
- accept_prob = 0.0
755
- new_spin = 0.0
756
- diff_energy = 0.0
757
-
758
- if cur_spin[cur_ind] == 0:
759
- if rand_flip[ii] == 0:
760
- new_spin = 1.0
761
- else:
762
- new_spin = -1.0
763
- diff_energy = -j_mat[cur_ind, cur_ind] - new_spin * (j_sub.dot(cur_spin) + h_vec[cur_ind])
764
- accept_prob = min(1.0, np.exp(- diff_energy * beta)[0])
762
+ # sweep index (no random site index)
763
+ cur_ind = ii % num_spin
764
+
765
+ # current spin and local field excluding self-term
766
+ s_i = cur_spin[cur_ind]
767
+ Jii = j_mat[cur_ind, cur_ind]
768
+ hstar = f_vec[cur_ind] - Jii * s_i
769
+
770
+ # heat-bath weights for k in {+1, 0, -1}:
771
+ # log-weights: a = beta*( hstar + 0.5*Jii ), b = 0, c = beta*( -hstar + 0.5*Jii )
772
+ a = beta * (hstar + Jii)
773
+ b = 0.0
774
+ c = beta * (-hstar + Jii)
775
+ m = max(a, b, c)
776
+ pa = np.exp(a - m)
777
+ pb = np.exp(b - m) # == exp(-m)
778
+ pc = np.exp(c - m)
779
+ Z = pa + pb + pc
780
+
781
+ # sample new spin
782
+ u = rand_prob[ii]
783
+ thresh_a = pa / Z
784
+ thresh_ab = (pa + pb) / Z
785
+ if u < thresh_a:
786
+ new_spin = 1.0
787
+ elif u < thresh_ab:
788
+ new_spin = 0.0
765
789
  else:
766
- if rand_flip[ii] == 0:
767
- accept_prob = 0;
768
- else:
769
- diff_energy = cur_spin[cur_ind] * (j_sub.dot(cur_spin) + h_vec[cur_ind])
770
- accept_prob = min(1.0, np.exp(- diff_energy * beta)[0])
771
-
772
- if rand_prob[ii] < accept_prob:
773
- if cur_spin[cur_ind] == 0:
774
- cur_spin[cur_ind] = new_spin
775
- else:
776
- cur_spin[cur_ind] = 0
777
-
778
- if ii > mixing_time:
779
- if (ii - mixing_time) % samp_gap == 0:
780
- rec_sample[:, batch_count - 1] = cur_spin[:, 0].copy()
781
- batch_count += 1
782
-
783
- if batch_count == per_batch + 1:
784
- batch_count = 1
785
- rec_sample = np.ascontiguousarray(rec_sample)
786
- rec_corr += rec_sample.dot(rec_sample.T)
787
- rec_mean += np.sum(rec_sample, axis=1)
788
-
789
- # Final processing of collected samples
790
- if batch_count != 1:
791
- cur_sample = rec_sample[:, :batch_count - 1]
792
- cur_sample = np.ascontiguousarray(cur_sample)
793
- rec_corr += cur_sample.dot(cur_sample.T)
794
- rec_mean += np.sum(cur_sample, axis=1)
790
+ new_spin = - 1.0
791
+
792
+ # if spin changed, update cur_spin and cached field
793
+ changed = (new_spin != s_i)
794
+ if changed:
795
+ delta_s = new_spin - s_i
796
+ cur_spin[cur_ind] = new_spin
797
+ # incremental local-field update: f_k += J[k,i] * delta_s
798
+ for k in range(num_spin):
799
+ f_vec[k] += j_mat[k, cur_ind] * delta_s
800
+
801
+ # If we're recording and have accumulated weight, flush rec_corr BEFORE changing last_outer
802
+ if recording_started and weight > 0 and have_last_outer:
803
+ for k in range(num_spin):
804
+ row = rec_corr[k]
805
+ lo_row = last_outer[k]
806
+ for l in range(num_spin):
807
+ row[l] += weight * lo_row[l]
808
+ weight = 0
809
+
810
+ # Update last_outer incrementally if it exists
811
+ if recording_started and have_last_outer:
812
+ d = delta_s
813
+ i = cur_ind
814
+ # s's' = s s^T + d (e_i s'^T + s' e_i^T) - d^2 e_i e_i^T (uses updated cur_spin)
815
+ for j in range(num_spin):
816
+ last_outer[i, j] += d * cur_spin[j]
817
+ for j in range(num_spin):
818
+ last_outer[j, i] += d * cur_spin[j]
819
+ last_outer[i, i] -= d * d
820
+
821
+ # Record sample on schedule
822
+ if ii >= mixing_time:
823
+ # mean: O(N)
824
+ for k in range(num_spin):
825
+ rec_mean[k] += cur_spin[k]
826
+
827
+ # initialize last_outer once at the first record
828
+ if not recording_started:
829
+ for k in range(num_spin):
830
+ sk = cur_spin[k]
831
+ row = last_outer[k]
832
+ for l in range(num_spin):
833
+ row[l] = sk * cur_spin[l]
834
+ have_last_outer = True
835
+ recording_started = True
836
+
837
+ # lazily count how many times to add current outer
838
+ weight += 1
839
+
840
+ # Flush any remaining weight after the loop
841
+ if recording_started and weight > 0 and have_last_outer:
842
+ for k in range(num_spin):
843
+ row = rec_corr[k]
844
+ lo_row = last_outer[k]
845
+ for l in range(num_spin):
846
+ row[l] += weight * lo_row[l]
795
847
 
796
848
  corr_para = rec_corr / sample_size
797
849
  mean_para = rec_mean / sample_size
798
-
850
+
799
851
  return corr_para, mean_para
800
852
 
801
853
 
854
+ @numba.njit()
855
+ def samp_mcmc_states(j_mat: np.ndarray,
856
+ h_vec: np.ndarray,
857
+ sample_size: int,
858
+ mixing_time: int,
859
+ samp_gap: int) -> np.ndarray:
860
+ """
861
+ Sample states for the Markov Chain Monte Carlo (MCMC).
862
+
863
+ Parameters
864
+ ----------
865
+ j_mat : np.ndarray
866
+ Interaction matrix.
867
+ h_vec : np.ndarray
868
+ External field vector.
869
+ sample_size : int
870
+ Size of the sample.
871
+ mixing_time : int
872
+ Mixing time for the MCMC.
873
+ samp_gap : int
874
+ Gap between samples.
875
+
876
+ Returns
877
+ -------
878
+ np.ndarray
879
+ The sampled states.
880
+ """
881
+ sample_size = int(sample_size)
882
+ mixing_time = int(mixing_time)
883
+ samp_gap = int(samp_gap)
884
+
885
+ num_spin = j_mat.shape[0]
886
+
887
+ beta = 1.0
888
+ batch_count = 1
889
+ rec_sample = np.empty((num_spin, int(sample_size)), dtype=np.float64)
890
+
891
+ # Cast & make contiguous (Numba-safe)
892
+ j_mat = np.ascontiguousarray(j_mat.astype(np.float64))
893
+ # Accept both (n,) and (n,1) h_vec; flatten to 1D
894
+ h_vec = h_vec.reshape(h_vec.size)
895
+ h_vec = np.ascontiguousarray(h_vec.astype(np.float64))
896
+
897
+ # Spins in {-1,0,1}
898
+ cur_spin = (np.random.randint(0, 3, num_spin) - 1).astype(np.float64)
899
+
900
+ # Local field f = h + J @ s (update incrementally on changes)
901
+ f_vec = h_vec + j_mat.dot(cur_spin)
902
+
903
+ # Sampling schedule (avoid modulo inside loop)
904
+ rem = mixing_time % samp_gap
905
+ first_sample = mixing_time if rem == 0 else (mixing_time + (samp_gap - rem))
906
+ last_sample = first_sample + (sample_size - 1) * samp_gap
907
+ tot_sampling = last_sample + 1
908
+ next_sample = first_sample
909
+
910
+ # Pre-generate randomness: one uniform per single-site update
911
+ rand_prob = np.random.rand(tot_sampling)
912
+
913
+ # MCMC (Gibbs) with deterministic sweep order
914
+ for ii in range(tot_sampling):
915
+ cur_ind = ii % num_spin
916
+
917
+ # ---- Compute conditional for s_i given s_-i (PRE-UPDATE neighborhood) ----
918
+ s_i = cur_spin[cur_ind]
919
+ Jii = j_mat[cur_ind, cur_ind]
920
+ # h*_i = h_i + sum_{j!=i} J_ij s_j = f_i - J_ii * s_i (pre-update)
921
+ hstar = f_vec[cur_ind] - Jii * s_i
922
+
923
+ # Unnormalized log-weights for k in {+1, 0, -1}
924
+ # Matches the enumerator convention (diagonal contributes +J_ii*k^2 here)
925
+ a = beta * (hstar + Jii) # log w(+1)
926
+ b = 0.0 # log w(0)
927
+ c = beta * (-hstar + Jii) # log w(-1)
928
+
929
+ # Stable normalization via max-trick
930
+ m = max(a, b, c)
931
+ pa = np.exp(a - m)
932
+ pb = np.exp(b - m)
933
+ pc = np.exp(c - m)
934
+ Z = pa + pb + pc
935
+
936
+ # Sample new spin from the conditional (heat-bath)
937
+ u = rand_prob[ii]
938
+ t1 = pa / Z
939
+ t2 = (pa + pb) / Z
940
+ if u < t1:
941
+ new_spin = 1.0
942
+ elif u < t2:
943
+ new_spin = 0.0
944
+ else:
945
+ new_spin = -1.0
946
+
947
+ # If changed, update spin and cached local field
948
+ if new_spin != s_i:
949
+ delta_s = new_spin - s_i
950
+ cur_spin[cur_ind] = new_spin
951
+ # Incremental local-field update: f_k += J[k, i] * delta_s
952
+ for k in range(num_spin):
953
+ f_vec[k] += j_mat[k, cur_ind] * delta_s
954
+
955
+ # Record sample on schedule
956
+ if ii == next_sample:
957
+ for k in range(num_spin):
958
+ rec_sample[k, batch_count - 1] = cur_spin[k]
959
+ batch_count += 1
960
+ next_sample += samp_gap
961
+
962
+ return rec_sample
963
+
964
+
802
965
  def _gradient_one_round(kk: int,
803
966
  cur_j: np.ndarray,
804
967
  cur_h: np.ndarray,
@@ -814,7 +977,7 @@ def _gradient_one_round(kk: int,
814
977
  corr_para, mean_para = para_moments(cur_j, cur_h[:, kk])
815
978
  elif method == 'mcmc_maximum_likelihood':
816
979
  corr_para, mean_para = samp_moments(
817
- cur_j, cur_h[:, kk], train_dat['mcmc_samplingsz'], train_dat['mcmc_samplingmix'], train_dat['mcmc_samplegap'])
980
+ cur_j, cur_h[:, kk], train_dat['mcmc_samplingsz'], train_dat['mcmc_samplingmix'])
818
981
  j_grad = corr_para - raw_data_k[0]
819
982
  h_grad = mean_para - raw_data_k[1].flatten()
820
983
 
@@ -985,6 +1148,7 @@ def apply_regularization(rec_jgrad: np.ndarray,
985
1148
 
986
1149
  if 'lambda_l2_h_rela_prior' in train_dat:
987
1150
  perturb_matrix = train_dat['perturb_matrix']
1151
+ rec_hgrad = average_batch_control(rec_hgrad, if_control, batch_index)
988
1152
  rec_hgrad += train_dat['lambda_l2_h_rela_prior'] * (h_rela - perturb_matrix)
989
1153
 
990
1154
  if 'lambda_l1_h_rela' in train_dat:
@@ -1421,7 +1585,7 @@ def compute_relative_responses(cur_h: np.ndarray,
1421
1585
  batch_samples_idx = np.where(batch_index == current_batch)[0]
1422
1586
 
1423
1587
  if np.any(if_control[batch_samples_idx]):
1424
- batch_controls_idx = batch_samples_idx[if_control[batch_samples_idx]]
1588
+ batch_controls_idx = batch_samples_idx[np.where(if_control[batch_samples_idx])[0]]
1425
1589
  batch_controls_mean = np.mean(cur_h[:, batch_controls_idx], axis=1)
1426
1590
  else:
1427
1591
  batch_controls_mean = all_controls_mean
@@ -1432,6 +1596,40 @@ def compute_relative_responses(cur_h: np.ndarray,
1432
1596
  return relative_h
1433
1597
 
1434
1598
 
1599
+ def average_batch_control(hgrad_raw: np.ndarray,
1600
+ if_control: np.ndarray,
1601
+ batch_index: np.ndarray) -> np.ndarray:
1602
+ """
1603
+ Assign the gradient of control samples as the average gradient of the samples in the same batch.
1604
+
1605
+ Parameters
1606
+ ----------
1607
+ hgrad : np.ndarray
1608
+ The gradient of the h matrix.
1609
+ if_control : np.ndarray
1610
+ A boolean array indicating which samples are control samples.
1611
+ batch_index : np.ndarray
1612
+ An array indicating the batch assignment for each sample.
1613
+ """
1614
+
1615
+ hgrad = hgrad_raw.copy()
1616
+
1617
+ unique_batches = np.unique(batch_index)
1618
+ num_batches = len(unique_batches)
1619
+
1620
+ for current_batch in unique_batches:
1621
+ batch_samples_idx = np.where(batch_index == current_batch)[0]
1622
+
1623
+ if np.any(if_control[batch_samples_idx]):
1624
+ if np.any(if_control[batch_samples_idx]):
1625
+ batch_controls_idx = batch_samples_idx[np.where(if_control[batch_samples_idx])[0]]
1626
+ batch_mean = np.mean(hgrad_raw[:, batch_samples_idx], axis=1)
1627
+
1628
+ hgrad[:, batch_controls_idx] = batch_mean[:, np.newaxis]
1629
+
1630
+ return hgrad
1631
+
1632
+
1435
1633
  def select_representative_sample(raw_data: List[Tuple[np.ndarray, np.ndarray]],
1436
1634
  num_select: int) -> List[int]:
1437
1635
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dspin
3
- Version: 1.4.2
3
+ Version: 1.5.0
4
4
  Summary: Regulatory network models from single-cell perturbation profiling
5
5
  Home-page: https://github.com/JialongJiang/DSPIN
6
6
  Author: Jialong Jiang
@@ -21,7 +21,7 @@ install_requires = [
21
21
  # ]
22
22
 
23
23
  # Managing your version (adjust accordingly)
24
- version = "1.4.2" # You can manage versioning in a more sophisticated way if needed
24
+ version = "1.5.0" # You can manage versioning in a more sophisticated way if needed
25
25
 
26
26
  # Long description (usually a README file)
27
27
  readme = open("README_PyPI.md").read() # Adjust the file name if necessary
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes