458 def lDDT(self, model, thresholds = [0.5, 1.0, 2.0, 4.0],
459 local_lddt_prop=None, local_contact_prop=None,
460 chain_mapping=None, no_interchain=False,
461 no_intrachain=False, penalize_extra_chains=False,
462 residue_mapping=None, return_dist_test=False,
463 check_resnames=True, add_mdl_contacts=False,
464 interaction_data=None, set_atom_props=False):
465 """Computes LDDT of *model* - globally and per-residue
467 :param model: Model to be scored - models are preferably scored upon
468 performing stereo-chemistry checks in order to punish for
469 non-sensical irregularities. This must be done separately
470 as a pre-processing step. Target contacts that are not
471 covered by *model* are considered not conserved, thus
472 decreasing LDDT score. This also includes missing model
473 chains or model chains for which no mapping is provided in
475 :type model: :class:`ost.mol.EntityHandle`/:class:`ost.mol.EntityView`
476 :param thresholds: Thresholds of distance differences to be considered
477 as correct - see docs in constructor for more info.
478 default: [0.5, 1.0, 2.0, 4.0]
479 :type thresholds: :class:`list` of :class:`floats`
480 :param local_lddt_prop: If set, per-residue scores will be assigned as
481 generic float property of that name
482 :type local_lddt_prop: :class:`str`
483 :param local_contact_prop: If set, number of expected contacts as well
484 as number of conserved contacts will be
485 assigned as generic int property.
486 Excected contacts will be set as
487 <local_contact_prop>_exp, conserved contacts
488 as <local_contact_prop>_cons. Values
489 are summed over all thresholds.
490 :type local_contact_prop: :class:`str`
491 :param chain_mapping: Mapping of model chains (key) onto target chains
492 (value). This is required if target or model have
494 :type chain_mapping: :class:`dict` with :class:`str` as keys/values
495 :param no_interchain: Whether to exclude interchain contacts
496 :type no_interchain: :class:`bool`
497 :param no_intrachain: Whether to exclude intrachain contacts (i.e. only
498 consider interface related contacts)
499 :type no_intrachain: :class:`bool`
500 :param penalize_extra_chains: Whether to include a fixed penalty for
501 additional chains in the model that are
502 not mapped to the target. ONLY AFFECTS
503 RETURNED GLOBAL SCORE. In detail: adds the
504 number of intra-chain contacts of each
505 extra chain to the expected contacts, thus
507 :type penalize_extra_chains: :class:`bool`
508 :param residue_mapping: By default, residue mapping is based on residue
509 numbers. That means, a model chain and the
510 respective target chain map to the same
511 underlying reference sequence (SEQRES).
512 Alternatively, you can specify one or
513 several alignment(s) between model and target
514 chains by providing a dictionary. key: Name
515 of chain in model (respective target chain is
516 extracted from *chain_mapping*),
517 value: Alignment with first sequence
518 corresponding to target chain and second
519 sequence to model chain. There is NO reference
520 sequence involved, so the two sequences MUST
521 exactly match the actual residues observed in
522 the respective target/model chains (ATOMSEQ).
523 :type residue_mapping: :class:`dict` with key: :class:`str`,
524 value: :class:`ost.seq.AlignmentHandle`
525 :param return_dist_test: Whether to additionally return the underlying
526 per-residue data for the distance difference
527 test. Adds five objects to the return tuple.
528 First: Number of total contacts summed over all
530 Second: Number of conserved contacts summed
532 Third: list with length of scored residues.
533 Contains indices referring to model.residues.
534 Fourth: numpy array of size
535 len(scored_residues) containing the number of
537 Fifth: numpy matrix of shape
538 (len(scored_residues), len(thresholds))
539 specifying how many for each threshold are
541 :param check_resnames: On by default. Enforces residue name matches
542 between mapped model and target residues.
543 :type check_resnames: :class:`bool`
544 :param add_mdl_contacts: Adds model contacts - Only using contacts that
545 are within a certain distance threshold in the
546 target does not penalize for added model
547 contacts. If set to True, this flag will also
548 consider target contacts that are within the
549 specified distance threshold in the model but
550 not necessarily in the target. No contact will
551 be added if the respective atom pair is not
552 resolved in the target.
553 :type add_mdl_contacts: :class:`bool`
554 :param interaction_data: Pro param - don't use
555 :type interaction_data: :class:`tuple`
556 :param set_atom_props: If True, sets generic properties on a per atom
557 level if *local_lddt_prop*/*local_contact_prop*
559 In other words: this is the only way you can
560 get per-atom LDDT values.
561 :type set_atom_props: :class:`bool`
563 :returns: global and per-residue LDDT scores as a tuple -
564 first element is global LDDT score (None if *target* has no
565 contacts) and second element a list of per-residue scores with
566 length len(*model*.residues). None is assigned to residues that
567 are not covered by target. If a residue is covered but has no
568 contacts in *target*, 0.0 is assigned.
570 if chain_mapping
is None:
571 if len(self.
chain_names) > 1
or len(model.chains) > 1:
572 raise NotImplementedError(
"Must provide chain mapping if "
573 "target or model have > 1 chains.")
574 chain_mapping = {model.chains[0].GetName(): self.
chain_names[0]}
577 for model_chain, target_chain
in chain_mapping.items():
579 raise RuntimeError(f
"Target chain specified in "
580 f
"chain_mapping ({target_chain}) does "
581 f
"not exist. Target has chains: "
582 f
"{self.chain_names}")
583 ch = model.FindChain(model_chain)
585 raise RuntimeError(f
"Model chain specified in "
586 f
"chain_mapping ({model_chain}) does "
587 f
"not exist. Model has chains: "
588 f
"{[c.GetName() for c in model.chains]}")
592 pos, res_ref_atom_indices, res_atom_indices, res_atom_hashes, \
593 res_indices, ref_res_indices, symmetries = \
595 residue_mapping = residue_mapping,
597 check_resnames = check_resnames)
599 if no_interchain
and no_intrachain:
600 raise RuntimeError(
"no_interchain and no_intrachain flags are "
601 "mutually exclusive")
603 sym_ref_indices =
None
604 sym_ref_distances =
None
608 if interaction_data
is None:
626 ref_indices, ref_distances = \
628 ref_indices, ref_distances,
629 no_interchain, no_intrachain)
631 sym_ref_indices, sym_ref_distances = \
633 ref_indices, ref_distances)
635 sym_ref_indices, sym_ref_distances, ref_indices, ref_distances = \
641 atom_indices = list(itertools.chain.from_iterable(res_atom_indices))
643 per_atom_exp = np.asarray([self.
_GetNExp(i, ref_indices)
644 for i
in atom_indices], dtype=np.int32)
645 per_res_exp = np.asarray([self.
_GetNExp(res_ref_atom_indices[idx],
646 ref_indices)
for idx
in range(len(res_indices))], dtype=np.int32)
648 per_atom_conserved = self.
_EvalAtoms(pos, atom_indices, thresholds,
649 ref_indices, ref_distances)
650 per_res_conserved = np.zeros((len(res_atom_indices), len(thresholds)),
653 for r_idx
in range(len(res_atom_indices)):
654 end_idx = start_idx + len(res_atom_indices[r_idx])
655 per_res_conserved[r_idx] = np.sum(per_atom_conserved[start_idx:end_idx,:],
659 n_thresh = len(thresholds)
662 per_res_lDDT = [
None] * model.GetResidueCount()
663 for idx
in range(len(res_indices)):
664 n_exp = n_thresh * per_res_exp[idx]
666 score = np.sum(per_res_conserved[idx,:]) / n_exp
667 per_res_lDDT[res_indices[idx]] = score
669 per_res_lDDT[res_indices[idx]] = 0.0
672 n_distances = sum([len(x)
for x
in ref_indices])
673 if penalize_extra_chains:
676 lDDT_tot = int(n_thresh * n_distances)
677 lDDT_cons = int(np.sum(per_res_conserved))
680 lDDT = float(lDDT_cons) / lDDT_tot
684 residues = model.residues
685 for idx
in res_indices:
686 residues[idx].SetFloatProp(local_lddt_prop, per_res_lDDT[idx])
688 if local_contact_prop:
689 residues = model.residues
690 exp_prop = local_contact_prop +
"_exp"
691 conserved_prop = local_contact_prop +
"_cons"
693 for i, r_idx
in enumerate(res_indices):
694 residues[r_idx].SetIntProp(exp_prop,
695 n_thresh * int(per_res_exp[i]))
696 residues[r_idx].SetIntProp(conserved_prop,
697 int(np.sum(per_res_conserved[i,:])))
699 if set_atom_props
and (local_lddt_prop
or local_contact_prop):
701 residues = model.residues
702 for i, indices
in enumerate(res_atom_indices):
703 r = residues[res_indices[i]]
704 r_idx = ref_res_indices[i]
708 a = r.FindAtom(anames[a_i - res_start_idx])
712 summed_per_atom_conserved = per_atom_conserved.sum(axis=1)
716 for a_idx
in range(len(atom_list)):
717 if per_atom_exp[a_idx] != 0:
718 tmp = summed_per_atom_conserved[a_idx] / per_atom_exp[a_idx]
720 atom_list[a_idx].SetFloatProp(local_lddt_prop, tmp)
722 if local_contact_prop:
723 conserved_prop = local_contact_prop +
"_cons"
724 exp_prop = local_contact_prop +
"_exp"
725 for a_idx
in range(len(atom_list)):
727 tmp = summed_per_atom_conserved[a_idx]
728 atom_list[a_idx].SetIntProp(conserved_prop, tmp)
730 tmp = per_atom_exp[a_idx] * n_thresh
731 atom_list[a_idx].SetIntProp(exp_prop, tmp)
734 return lDDT, per_res_lDDT, lDDT_tot, lDDT_cons, res_indices, \
735 per_res_exp, per_res_conserved
737 return lDDT, per_res_lDDT
739 def DRMSD(self, model, dist_cap = 5,
740 chain_mapping=None, no_interchain=False,
741 no_intrachain=False, residue_mapping=None,
742 check_resnames=True, add_mdl_contacts=False,
743 interaction_data=None):
744 """ DRMSD of *model* - globally and per-residue
746 Very similar to LDDT as we operate on distance differences for all
747 interatomic distances within the same inclusion radius as in LDDT.
748 DRMSD is the distance rmsd, i.e. the RMSD of distance differences.
749 Distance differences are capped at *dist_cap* which is also the default
750 value for missing distances.
752 :param model: Model to be scored - models are preferably scored upon
753 performing stereo-chemistry checks in order to punish for
754 non-sensical irregularities. This must be done separately
755 as a pre-processing step. Target contacts that are not
756 covered by *model* are considered not conserved, thus
757 increasing DRMSD score. This also includes missing model
758 chains or model chains for which no mapping is provided in
760 :type model: :class:`ost.mol.EntityHandle`/:class:`ost.mol.EntityView`
761 :param dist_cap: Cap for distance differences.
762 :type dist_cap: :class:`float`
763 :param chain_mapping: Mapping of model chains (key) onto target chains
764 (value). This is required if target or model have
766 :type chain_mapping: :class:`dict` with :class:`str` as keys/values
767 :param no_interchain: Whether to exclude interchain contacts
768 :type no_interchain: :class:`bool`
769 :param no_intrachain: Whether to exclude intrachain contacts (i.e. only
770 consider interface related contacts)
771 :type no_intrachain: :class:`bool`
772 :param residue_mapping: By default, residue mapping is based on residue
773 numbers. That means, a model chain and the
774 respective target chain map to the same
775 underlying reference sequence (SEQRES).
776 Alternatively, you can specify one or
777 several alignment(s) between model and target
778 chains by providing a dictionary. key: Name
779 of chain in model (respective target chain is
780 extracted from *chain_mapping*),
781 value: Alignment with first sequence
782 corresponding to target chain and second
783 sequence to model chain. There is NO reference
784 sequence involved, so the two sequences MUST
785 exactly match the actual residues observed in
786 the respective target/model chains (ATOMSEQ).
787 :type residue_mapping: :class:`dict` with key: :class:`str`,
788 value: :class:`ost.seq.AlignmentHandle`
789 :param check_resnames: On by default. Enforces residue name matches
790 between mapped model and target residues.
791 :type check_resnames: :class:`bool`
792 :param add_mdl_contacts: Adds model contacts - Only using contacts that
793 are within a certain distance threshold in the
794 target does not penalize for added model
795 contacts. If set to True, this flag will also
796 consider target contacts that are within the
797 specified distance threshold in the model but
798 not necessarily in the target. No contact will
799 be added if the respective atom pair is not
800 resolved in the target.
801 :type add_mdl_contacts: :class:`bool`
802 :param interaction_data: Pro param - don't use
803 :type interaction_data: :class:`tuple`
805 :returns: global and per-residue DRMSD scores as a tuple -
806 first element is global DRMSD score (None if *target* has no
807 contacts) and second element a list of per-residue scores with
808 length len(*model*.residues). None is assigned to residues that
809 are not covered by target. If a residue is covered but has no
810 contacts in *target*, None is assigned.
812 if chain_mapping
is None:
813 if len(self.
chain_names) > 1
or len(model.chains) > 1:
814 raise NotImplementedError(
"Must provide chain mapping if "
815 "target or model have > 1 chains.")
816 chain_mapping = {model.chains[0].GetName(): self.
chain_names[0]}
819 for model_chain, target_chain
in chain_mapping.items():
821 raise RuntimeError(f
"Target chain specified in "
822 f
"chain_mapping ({target_chain}) does "
823 f
"not exist. Target has chains: "
824 f
"{self.chain_names}")
825 ch = model.FindChain(model_chain)
827 raise RuntimeError(f
"Model chain specified in "
828 f
"chain_mapping ({model_chain}) does "
829 f
"not exist. Model has chains: "
830 f
"{[c.GetName() for c in model.chains]}")
834 pos, res_ref_atom_indices, res_atom_indices, res_atom_hashes, \
835 res_indices, ref_res_indices, symmetries = \
837 residue_mapping = residue_mapping,
839 check_resnames = check_resnames)
841 if no_interchain
and no_intrachain:
842 raise RuntimeError(
"no_interchain and no_intrachain flags are "
843 "mutually exclusive")
845 sym_ref_indices =
None
846 sym_ref_distances =
None
850 if interaction_data
is None:
868 ref_indices, ref_distances = \
870 ref_indices, ref_distances,
871 no_interchain, no_intrachain)
873 sym_ref_indices, sym_ref_distances = \
875 ref_indices, ref_distances)
877 sym_ref_indices, sym_ref_distances, ref_indices, ref_distances = \
883 atom_indices = list(itertools.chain.from_iterable(res_atom_indices))
885 per_atom_exp = np.asarray([self.
_GetNExp(i, ref_indices)
886 for i
in atom_indices], dtype=np.int32)
887 per_res_exp = np.asarray([self.
_GetNExp(res_ref_atom_indices[idx],
888 ref_indices)
for idx
in range(len(res_indices))], dtype=np.int32)
889 per_atom_ssd = self.
_EvalAtomsSSD(pos, atom_indices, dist_cap,
890 ref_indices, ref_distances)
894 per_res_drmsd = [
None] * model.GetResidueCount()
895 for r_idx
in range(len(res_atom_indices)):
896 end_idx = start_idx + len(res_atom_indices[r_idx])
897 n_tot = per_res_exp[r_idx]
899 ssd = np.sum(per_atom_ssd[start_idx:end_idx])
902 n_missing = n_tot - np.sum(per_atom_exp[start_idx:end_idx])
903 ssd += n_missing*dist_cap*dist_cap
904 per_res_drmsd[res_indices[r_idx]] = np.sqrt(ssd/n_tot)
909 n_tot = sum([len(x)
for x
in ref_indices])
911 ssd = np.sum(per_atom_ssd)
914 n_missing = n_tot - np.sum(per_atom_exp)
915 ssd += (dist_cap*dist_cap*n_missing)
916 drmsd = np.sqrt(ssd/n_tot)
918 return drmsd, per_res_drmsd
945 check_resnames = True):
946 """ Helper that generates data structures from model
951 max_pos = model.bounds.GetMax()
952 max_coordinate = abs(max(max_pos[0], max_pos[1], max_pos[2]))
953 max_coordinate += 42 * nirvana_dist
954 pos = np.ones((self.
n_atoms, 3), dtype=np.float32) * max_coordinate
958 res_ref_atom_indices = list()
962 res_atom_indices = list()
966 res_atom_hashes = list()
972 ref_res_indices = list()
977 current_model_res_idx = -1
978 for ch
in model.chains:
979 model_ch_name = ch.GetName()
980 if model_ch_name
not in chain_mapping:
981 current_model_res_idx += len(ch.residues)
983 target_ch_name = chain_mapping[model_ch_name]
988 for r, rnum
in zip(ch.residues, rnums):
989 current_model_res_idx += 1
990 res_mapper_key = (target_ch_name, rnum)
996 f
"Residue name mismatch for {r}, "
997 f
" expect {self.compound_names[r_idx]}"
1002 atoms = [r.FindAtom(aname)
for aname
in anames]
1003 res_ref_atom_indices.append(
1004 list(range(res_start_idx, res_start_idx + len(anames)))
1006 res_atom_indices.append(list())
1007 res_atom_hashes.append(list())
1008 res_indices.append(current_model_res_idx)
1009 ref_res_indices.append(r_idx)
1010 for a_idx, a
in enumerate(atoms):
1013 pos[res_start_idx + a_idx][0] = p[0]
1014 pos[res_start_idx + a_idx][1] = p[1]
1015 pos[res_start_idx + a_idx][2] = p[2]
1016 res_atom_indices[-1].append(res_start_idx + a_idx)
1017 res_atom_hashes[-1].append(a.handle.GetHashCode())
1019 sym_indices = list()
1021 a_one = atoms[sym_tuple[0]]
1022 a_two = atoms[sym_tuple[1]]
1023 if a_one.IsValid()
and a_two.IsValid():
1026 res_start_idx + sym_tuple[0],
1027 res_start_idx + sym_tuple[1],
1030 if len(sym_indices) > 0:
1031 symmetries.append(sym_indices)
1033 return (pos, res_ref_atom_indices, res_atom_indices, res_atom_hashes,
1034 res_indices, ref_res_indices, symmetries)
1242 ref_indices, ref_distances, no_interchain,
1246 in_target = np.zeros(self.
n_atoms, dtype=bool)
1249 mdl_atom_indices = dict()
1250 for at_indices, at_hashes
in zip(res_atom_indices, res_atom_hashes):
1251 for i, h
in zip(at_indices, at_hashes):
1253 mdl_atom_indices[h] = i
1258 mdl_ref_indices, mdl_ref_distances = \
1259 lDDTScorer._SetupDistances(model, self.
n_atoms, mdl_atom_indices,
1262 mdl_ref_indices, mdl_ref_distances = \
1263 lDDTScorer._SetupDistancesSC(self.
n_atoms,
1269 mdl_ref_indices, mdl_ref_distances = \
1270 lDDTScorer._SetupDistancesIC(self.
n_atoms,
1277 mask = np.isin(mdl_ref_indices[i], ref_indices[i],
1278 assume_unique=
True, invert=
True)
1279 if np.sum(mask) > 0:
1280 added_mdl_indices = mdl_ref_indices[i][mask]
1281 ref_indices[i] = np.append(ref_indices[i],
1285 tmp = self.
positions.take(added_mdl_indices, axis=0)
1286 np.subtract(tmp, self.
positions[i][
None, :], out=tmp)
1287 np.square(tmp, out=tmp)
1288 tmp = tmp.sum(axis=1)
1289 np.sqrt(tmp, out=tmp)
1290 ref_distances[i] = np.append(ref_distances[i], tmp)
1292 return (ref_indices, ref_distances)
1300 """Compute distance related members of lDDTScorer
1302 Brute force all vs all distance computation kills LDDT for large
1303 complexes. Instead of building some KD tree data structure, we make use
1304 of expected spatial proximity of atoms in the same chain. Distances are
1305 computed as follows:
1307 - process each chain individually
1308 - perform crude collision detection
1309 - process potentially interacting chain pairs
1310 - concatenate distances from all processing steps
1312 ref_indices = [np.asarray([], dtype=np.int32)
for idx
in range(n_atoms)]
1313 ref_distances = [np.asarray([], dtype=np.float32)
for idx
in range(n_atoms)]
1315 indices = [list()
for _
in range(n_atoms)]
1316 distances = [list()
for _
in range(n_atoms)]
1317 per_chain_pos = list()
1318 per_chain_indices = list()
1321 for ch
in structure.chains:
1323 atom_indices = list()
1327 for r_idx, r
in enumerate(ch.residues):
1330 hash_code = a.handle.GetHashCode()
1331 if hash_code
in atom_index_mapping:
1333 pos_list.append(np.asarray([p[0], p[1], p[2]], dtype=np.float32))
1334 atom_indices.append(atom_index_mapping[hash_code])
1336 mask_start.extend([r_start_idx] * n_valid_atoms)
1337 mask_end.extend([r_start_idx + n_valid_atoms] * n_valid_atoms)
1338 r_start_idx += n_valid_atoms
1340 if len(pos_list) == 0:
1344 pos = np.vstack(pos_list)
1345 atom_indices = np.asarray(atom_indices, dtype=np.int32)
1347 if atom_indices.shape[0] > 20000:
1350 dists = cdist(pos, pos)
1353 far_away = 2 * inclusion_radius
1354 for idx
in range(atom_indices.shape[0]):
1355 dists[idx, range(mask_start[idx], mask_end[idx])] = far_away
1358 within_mask = dists < inclusion_radius
1359 for idx
in range(atom_indices.shape[0]):
1360 indices_to_append = atom_indices[within_mask[idx,:]]
1361 if indices_to_append.shape[0] > 0:
1362 full_at_idx = atom_indices[idx]
1363 indices[full_at_idx].append(indices_to_append)
1364 distances[full_at_idx].append(dists[idx, within_mask[idx,:]])
1368 per_chain_pos.append(pos)
1369 per_chain_indices.append(atom_indices)
1372 min_pos = [p.min(0)
for p
in per_chain_pos]
1373 max_pos = [p.max(0)
for p
in per_chain_pos]
1374 chain_pairs = list()
1375 for idx_one
in range(len(per_chain_pos)):
1376 for idx_two
in range(idx_one + 1, len(per_chain_pos)):
1377 if np.max(min_pos[idx_one] - max_pos[idx_two]) > inclusion_radius:
1379 if np.max(min_pos[idx_two] - max_pos[idx_one]) > inclusion_radius:
1381 chain_pairs.append((idx_one, idx_two))
1384 for pair
in chain_pairs:
1385 if per_chain_pos[pair[0]].shape[0] > 20000
or per_chain_pos[pair[1]].shape[0] > 20000:
1386 dists =
blockwise_cdist(per_chain_pos[pair[0]], per_chain_pos[pair[1]])
1388 dists = cdist(per_chain_pos[pair[0]], per_chain_pos[pair[1]])
1389 within = dists <= inclusion_radius
1392 tmp = within.sum(axis=1)
1393 for idx
in range(tmp.shape[0]):
1398 at_idx = per_chain_indices[pair[0]][idx]
1399 indices_to_insert = per_chain_indices[pair[1]][within[idx,:]]
1400 distances_to_insert = dists[idx, within[idx, :]]
1401 insertion_idx = len(indices[at_idx])
1402 for i
in range(insertion_idx):
1403 if indices_to_insert[0] > indices[at_idx][i][0]:
1406 indices[at_idx].insert(insertion_idx, indices_to_insert)
1407 distances[at_idx].insert(insertion_idx, distances_to_insert)
1410 tmp = within.sum(axis=0)
1411 for idx
in range(tmp.shape[0]):
1416 at_idx = per_chain_indices[pair[1]][idx]
1417 indices_to_insert = per_chain_indices[pair[0]][within[:, idx]]
1418 distances_to_insert = dists[within[:, idx], idx]
1419 insertion_idx = len(indices[at_idx])
1420 for i
in range(insertion_idx):
1421 if indices_to_insert[0] > indices[at_idx][i][0]:
1424 indices[at_idx].insert(insertion_idx, indices_to_insert)
1425 distances[at_idx].insert(insertion_idx, distances_to_insert)
1430 for at_idx
in range(n_atoms):
1431 if len(indices[at_idx]) > 0:
1432 ref_indices[at_idx] = np.hstack(indices[at_idx])
1433 ref_distances[at_idx] = np.hstack(distances[at_idx])
1435 return (ref_indices, ref_distances)