From 409815b5e85540bc6fcf5d3bd6f9088343a9d6ce Mon Sep 17 00:00:00 2001 From: chendanyang Date: Sat, 29 Nov 2025 12:14:12 +0800 Subject: [PATCH] add ProteinMPNN --- .jenkins/check/config/filter_pylint.txt | 18 + .jenkins/check/config/whitelizard.txt | 6 + MindSPONGE/README.md | 2 +- MindSPONGE/README_en.md | 2 +- MindSPONGE/applications/proteinmpnn/NOTICE | 4 + MindSPONGE/applications/proteinmpnn/README.md | 177 ++ .../applications/proteinmpnn/README_en.md | 177 ++ .../proteinmpnn/examples/ab_pdb_example.sh | 5 + .../proteinmpnn/examples/submit_example_1.sh | 21 + .../proteinmpnn/examples/submit_example_2.sh | 26 + .../proteinmpnn/examples/submit_example_3.sh | 20 + .../examples/submit_example_3_score_only.sh | 21 + .../submit_example_3_score_only_from_fasta.sh | 23 + .../proteinmpnn/examples/submit_example_4.sh | 33 + .../examples/submit_example_4_non_fixed.sh | 33 + .../proteinmpnn/examples/submit_example_5.sh | 37 + .../proteinmpnn/examples/submit_example_6.sh | 26 + .../proteinmpnn/examples/submit_example_7.sh | 22 + .../proteinmpnn/examples/submit_example_8.sh | 27 + .../examples/submit_example_pssm.sh | 42 + .../helper_scripts/assign_fixed_chains.py | 54 + .../helper_scripts/make_bias_AA.py | 41 + .../make_fixed_positions_dict.py | 75 + .../helper_scripts/make_pssm_input_dict.py | 54 + .../make_tied_positions_dict.py | 69 + .../helper_scripts/parse_multiple_chains.py | 90 + .../proteinmpnn/img/github_fig.png | Bin 0 -> 111513 bytes .../proteinmpnn/proteinmpnn/__init__.py | 0 .../proteinmpnn/proteinmpnn/protein_mpnn.py | 1843 +++++++++++++++++ .../proteinmpnn/sample_features.py | 134 ++ .../proteinmpnn/proteinmpnn/struct_manager.py | 125 ++ .../proteinmpnn/util_protein_mpnn.py | 1221 +++++++++++ .../proteinmpnn_interface_design.py | 267 +++ .../proteinmpnn/proteinmpnn_run.py | 750 +++++++ .../applications/proteinmpnn/requirements.txt | 1 + .../proteinmpnn/scripts/download_weights.sh | 31 + 36 files changed, 5475 insertions(+), 2 deletions(-) create mode 100644 MindSPONGE/applications/proteinmpnn/NOTICE create mode 100644 MindSPONGE/applications/proteinmpnn/README.md create mode 100644 MindSPONGE/applications/proteinmpnn/README_en.md create mode 100644 MindSPONGE/applications/proteinmpnn/examples/ab_pdb_example.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_1.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_2.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_3.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_3_score_only.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_3_score_only_from_fasta.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_4.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_4_non_fixed.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_5.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_6.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_7.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_8.sh create mode 100644 MindSPONGE/applications/proteinmpnn/examples/submit_example_pssm.sh create mode 100644 MindSPONGE/applications/proteinmpnn/helper_scripts/assign_fixed_chains.py create mode 100644 MindSPONGE/applications/proteinmpnn/helper_scripts/make_bias_AA.py create mode 100644 MindSPONGE/applications/proteinmpnn/helper_scripts/make_fixed_positions_dict.py create mode 100644 MindSPONGE/applications/proteinmpnn/helper_scripts/make_pssm_input_dict.py create mode 100644 MindSPONGE/applications/proteinmpnn/helper_scripts/make_tied_positions_dict.py create mode 100644 MindSPONGE/applications/proteinmpnn/helper_scripts/parse_multiple_chains.py create mode 100644 MindSPONGE/applications/proteinmpnn/img/github_fig.png create mode 100644 MindSPONGE/applications/proteinmpnn/proteinmpnn/__init__.py create mode 100644 MindSPONGE/applications/proteinmpnn/proteinmpnn/protein_mpnn.py create mode 100644 MindSPONGE/applications/proteinmpnn/proteinmpnn/sample_features.py create mode 100644 MindSPONGE/applications/proteinmpnn/proteinmpnn/struct_manager.py create mode 100644 MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py create mode 100644 MindSPONGE/applications/proteinmpnn/proteinmpnn_interface_design.py create mode 100644 MindSPONGE/applications/proteinmpnn/proteinmpnn_run.py create mode 100644 MindSPONGE/applications/proteinmpnn/requirements.txt create mode 100644 MindSPONGE/applications/proteinmpnn/scripts/download_weights.sh diff --git a/.jenkins/check/config/filter_pylint.txt b/.jenkins/check/config/filter_pylint.txt index 8925293ab..3ee231a48 100644 --- a/.jenkins/check/config/filter_pylint.txt +++ b/.jenkins/check/config/filter_pylint.txt @@ -398,3 +398,21 @@ "mindscience/MindSPONGE/applications/rf_diffusion/run_inference.py" "no-value-for-parameter" "mindscience/MindSPONGE/applications/rf_diffusion/rfdiffusion/inference/ab_util.py" "missing-function-docstring" "mindscience/MindSPONGE/applications/rf_diffusion/run_inference.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/helper_scripts/assign_fixed_chains.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/helper_scripts/make_bias_AA.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/helper_scripts/make_fixed_positions_dict.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/helper_scripts/make_pssm_input_dict.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/helper_scripts/make_tied_positions_dict.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/helper_scripts/parse_multiple_chains.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/protein_mpnn.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/protein_mpnn.py" "import-outside-toplevel" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/protein_mpnn.py" "too-many-nested-blocks" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/protein_mpnn.py" "undefined-loop-variable" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/sample_features.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/struct_manager.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py" "too-many-nested-blocks" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py" "too-many-function-args" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn_interface_design.py" "invalid-name" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn_interface_design.py" "redefined-outer-name" +"mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn_run.py" "invalid-name" diff --git a/.jenkins/check/config/whitelizard.txt b/.jenkins/check/config/whitelizard.txt index dd435a60c..bf5f8f5dc 100644 --- a/.jenkins/check/config/whitelizard.txt +++ b/.jenkins/check/config/whitelizard.txt @@ -26,6 +26,12 @@ mindscience/MindSPONGE/applications/rf_diffusion/env/se3_transformer/model/layer mindscience/MindSPONGE/applications/rf_diffusion/env/se3_transformer/model/layers/convolution.py:construct mindscience/MindSPONGE/applications/rf_diffusion/run_inference.py:main mindscience/MindSPONGE/applications/rf_diffusion/rfdiffusion/inference/ab_util.py:ab_write_pdblines +mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py:ab_write_pdblines +mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py:parse_PDB_biounits +mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py:parse_PDB +mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/protein_mpnn.py:tied_featurize +mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/protein_mpnn.py:sample +mindscience/MindSPONGE/applications/proteinmpnn/proteinmpnn/protein_mpnn.py:tied_sample #MindChem mindscience/MindChem/applications/deephe3nn/data/graph.py:get_graph diff --git a/MindSPONGE/README.md b/MindSPONGE/README.md index 8ade6d0ac..1bafafb6a 100644 --- a/MindSPONGE/README.md +++ b/MindSPONGE/README.md @@ -70,7 +70,7 @@ MindSpore SPONGE(Simulation Package tOwards Next GEneration molecular modelling) #### 结构序列联合设计 -- ProteinMPNN [[Available]](https://gitee.com/mindspore/mindscience/blob/legacy-master/MindSPONGE/applications/model_cards/ProteinMPNN.MD) +- ProteinMPNN [[Available]](./applications/proteinmpnn/) - ESM-IF1 [[Available]](https://gitee.com/mindspore/mindscience/blob/legacy-master/MindSPONGE/applications/model_cards/ESM-IF1.md) - ColabDesign [[Available]](https://gitee.com/mindspore/mindscience/blob/legacy-master/MindSPONGE/applications/model_cards/ColabDesign.md) diff --git a/MindSPONGE/README_en.md b/MindSPONGE/README_en.md index 79e395bcb..3e3a9c7bd 100644 --- a/MindSPONGE/README_en.md +++ b/MindSPONGE/README_en.md @@ -68,7 +68,7 @@ MindSpore SPONGE (Simulation Package tOwards Next GEneration molecular modelling #### Structure-Sequence Co-design -- ProteinMPNN [[Available]](https://gitee.com/mindspore/mindscience/blob/legacy-master/MindSPONGE/applications/model_cards/ProteinMPNN.MD) +- ProteinMPNN [[Available]](./applications/proteinmpnn/) - ESM-IF1 [[Available]](https://gitee.com/mindspore/mindscience/blob/legacy-master/MindSPONGE/applications/model_cards/ESM-IF1.md) - ColabDesign [[Available]](https://gitee.com/mindspore/mindscience/blob/legacy-master/MindSPONGE/applications/model_cards/ColabDesign.md) diff --git a/MindSPONGE/applications/proteinmpnn/NOTICE b/MindSPONGE/applications/proteinmpnn/NOTICE new file mode 100644 index 000000000..90a643150 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/NOTICE @@ -0,0 +1,4 @@ +ProteinMPNN + +This repository includes code from https://github.com/dauparas/ProteinMPNN +licensed under the MIT License. \ No newline at end of file diff --git a/MindSPONGE/applications/proteinmpnn/README.md b/MindSPONGE/applications/proteinmpnn/README.md new file mode 100644 index 000000000..d900b4d32 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/README.md @@ -0,0 +1,177 @@ +# ProteinMPNN + +

+ alt text +

+ +## 描述 + +ProteinMPNN 是一个开源的深度学习方法,用于在给定蛋白质骨架上进行序列设计。它能够快速生成可折叠到目标三维结构的高质量氨基酸序列,适用于从单体设计到抗体序列设计的广泛应用,详见 [ProteinMPNN 论文](https://www.science.org/doi/10.1126/science.add2187)。 + +本仓库提供了基于 MindSpore 的 ProteinMPNN 实现,改写自原始的 [ProteinMPNN](https://github.com/dauparas/ProteinMPNN) 仓库,并与 [RFantibody](https://github.com/RosettaCommons/RFantibody) 中的抗体相关工具进行集成。 + +--- + +## 快速开始 / 安装 + +基础环境要求: + +```text +python >= 3.11 +mindspore >= 2.7.1 +CANN >= 8.2.RC1 +``` + +克隆 MindScience 仓库: + +```bash +git clone https://gitee.com/mindspore/mindscience.git +``` + +下载模型权重到 ProteinMPNN 目录: + +```bash +cd mindscience/MindSPONGE/applications/proteinmpnn +bash scripts/download_weights.sh +``` + +### 配置 Python 环境 + +安装依赖包: + +```bash +pip install -r requirements.txt +``` + +--- + +### 获取示例 PDBs + +为了运行示例,我们提供了部分示例 PDB 文件。 +需要先解压: + +```bash +unzip examples/example_inputs.zip -d examples/ +``` + +--- + +## 使用方法 + +### ProteinMPNN 基本用法 + +以下示例可在仓库根目录运行: + +- 单体设计: + + ```bash + bash examples/submit_example_1.sh + ``` + + 解析单体 PDB 并为每个目标设计 2 条序列,输出到 `examples/example_outputs/example_1_outputs`。 + +- 选择链进行复合体设计: + + ```bash + bash examples/submit_example_2.sh + ``` + + 解析复合体并设置固定链;仅为链 `A B` 进行设计。 + +- 单个复合体 PDB 设计: + + ```bash + bash examples/submit_example_3.sh + ``` + + 为单个 PDB 中的链 `A B` 进行序列设计。 + +- 仅评分(PDB): + + ```bash + bash examples/submit_example_3_score_only.sh + ``` + + 对原始序列进行模型评分,不生成新序列。 + +- 仅评分(FASTA+PDB): + + ```bash + bash examples/submit_example_3_score_only_from_fasta.sh + ``` + + 对 FASTA 文件中的序列进行评分。 + +- 固定/非固定残基: + + ```bash + bash examples/submit_example_4.sh + bash examples/submit_example_4_non_fixed.sh + ``` + + 使用 `helper_scripts/make_fixed_positions_dict.py` 固定残基(不设计)或仅设计指定残基。 + +- 跨链绑定位点: + + ```bash + bash examples/submit_example_5.sh + ``` + + 使用 `helper_scripts/make_tied_positions_dict.py` 在多个链上的指定位置采样相同氨基酸。 + +- 同源寡聚体的绑定位点设计: + + ```bash + bash examples/submit_example_6.sh + ``` + + 通过 `--homooligomer 1` 在相同链的等价位置上进行绑定位点设计。 + +- 仅输出非条件概率: + + ```bash + bash examples/submit_example_7.sh + ``` + + 输出每个位置的非条件对数概率(结果在 `unconditional_probs_only` 下)。 + +- 全局氨基酸偏置(例如:极性偏置): + + ```bash + bash examples/submit_example_8.sh + ``` + + 生成偏置字典,并通过 `--bias_AA_jsonl` 参与设计。 + +- 基于 PSSM 的引导设计: + + ```bash + bash examples/submit_example_pssm.sh + ``` + + 将 ProteinMPNN 的 logits 与 PSSM 概率进行混合。使用 `--pssm_multi` 控制全局混合(0=不使用 PSSM,1=仅使用 PSSM),并通过 `helper_scripts/make_pssm_input_dict.py` 设置每个残基的系数。通过 `--pssm_bias_flag` 启用偏置分布。 + +### 来自 RFantibody 的抗体 CDR 设计 + +对 HLT 格式的 .pdb 进行 CDR 设计,运行: + +```bash +python proteinmpnn_interface_design.py \ + -pdbdir /path/to/inputdir \ + -outpdbdir /path/to/outputdir +``` + +该命令将对所有 CDR 环进行设计,并为每个输入结构生成 1 条序列。更多参数可通过查看: + +```bash +python proteinmpnn_interface_design.py --help +``` + +示例命令: + +```bash +bash examples/ab_pdb_example.sh +``` + +> Modified from [ProteinMPNN](https://github.com/dauparas/ProteinMPNN) +> Original license: MIT License diff --git a/MindSPONGE/applications/proteinmpnn/README_en.md b/MindSPONGE/applications/proteinmpnn/README_en.md new file mode 100644 index 000000000..90db3a7ec --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/README_en.md @@ -0,0 +1,177 @@ +# ProteinMPNN + +

+ alt text +

+ +## Description + +ProteinMPNN is an open-source deep-learning method for sequence design on given protein backbones. It rapidly generates high-quality amino-acid sequences that fold into the desired 3-D structure, enabling a wide range of applications from monomer design to complex interface engineering as described in [the ProteinMPNN paper](https://www.science.org/doi/10.1126/science.add2187). + +This repository provides a MindSpore implementation of ProteinMPNN, adapted from the original [ProteinMPNN](https://github.com/dauparas/ProteinMPNN) repository and integrated with antibody-specific utilities from [RFantibody](https://github.com/RosettaCommons/RFantibody). + +--- + +## Getting started / installation + +Basic requirements: + +```text +python >= 3.11 +mindspore >= 2.7.1 +CANN >= 8.2.RC1 +``` + +To get started using ProteinMPNN, clone the MindScience repository: + +```bash +git clone https://gitee.com/mindspore/mindscience.git +``` + +Then download model weights using the provided script into the ProteinMPNN directory: + +```bash +cd mindscience/MindSPONGE/applications/proteinmpnn +bash scripts/download_models.sh +``` + +### Configure Python Environment + +Install the requirement python packages: + +```bash +pip install -r requirements.txt +``` + +--- + +### Get Example PDBs + +To run the examples, we have provided some example pdb. +You'll need to unzip this: + +```bash +unzip examples/example_inputs.zip -d examples/ +``` + +--- + +## Usage + +### Basic usages for ProteinMPNN + +The following examples can be run from the repository root: + +- Monomer design over a folder: + + ```bash + bash examples/submit_example_1.sh + ``` + + Parses monomer PDBs and designs 2 sequences per target into `examples/example_outputs/example_1_outputs`. + +- Complex design with selected designed chains: + + ```bash + bash examples/submit_example_2.sh + ``` + + Parses complexes and assigns fixed chains; designs only chains `A B`. + +- Single complex PDB design: + + ```bash + bash examples/submit_example_3.sh + ``` + + Designs sequences for chains `A B` in a single PDB. + +- Score-only on a PDB: + + ```bash + bash examples/submit_example_3_score_only.sh + ``` + + Computes model scores for the native sequence without generating new sequences. + +- Score-only from FASTA against a PDB: + + ```bash + bash examples/submit_example_3_score_only_from_fasta.sh + ``` + + Evaluates how well sequences from a FASTA file match the 3-D structure by scoring them. + +- Fixed/non-fixed positions: + + ```bash + bash examples/submit_example_4.sh + bash examples/submit_example_4_non_fixed.sh + ``` + + Fixes residues (not designed) or designs only specific residues using `helper_scripts/make_fixed_positions_dict.py`. + +- Tied positions across chains: + + ```bash + bash examples/submit_example_5.sh + ``` + + Samples the same residue identities at specified positions across multiple chains using `helper_scripts/make_tied_positions_dict.py`. + +- Homooligomer design with chain tying: + + ```bash + bash examples/submit_example_6.sh + ``` + + Uses `--homooligomer 1` to tie equivalent positions across identical chains. + +- Output unconditional probabilities only: + + ```bash + bash examples/submit_example_7.sh + ``` + + Produces per-position unconditional log-probabilities (outputs under `unconditional_probs_only`). + +- Global amino-acid bias (e.g., polar bias): + + ```bash + bash examples/submit_example_8.sh + ``` + + Generates a bias dictionary and designs with `--bias_AA_jsonl`. + +- PSSM-guided design: + + ```bash + bash examples/submit_example_pssm.sh + ``` + + Combines ProteinMPNN logits with PSSM-derived probabilities. Control the global mixture with `--pssm_multi` (0=no PSSM, 1=only PSSM) and per-residue coefficients via `helper_scripts/make_pssm_input_dict.py`. Enable bias distribution with `--pssm_bias_flag`. + +### Antibody CDR Design from RFantibody + +To perform CDR loop design from RFantibody, ProteinMPNN may be run on a directory of HLT-formatted .pdb files using the following command: + +```bash +python proteinmpnn_interface_design.py \ + -pdbdir /path/to/inputdir \ + -outpdbdir /path/to/outputdir +``` + +This will design all CDR loops and will provide one sequence per input structure. There are many more arguments that may be experimented with and are explained by running: + +```bash +python proteinmpnn_interface_design.py --help +``` + +We provide an example command with example inputs which can be run as follows: + +```bash +bash examples/ab_pdb_example.sh +``` + +> Modified from [ProteinMPNN](https://github.com/dauparas/ProteinMPNN) +> Original license: MIT License diff --git a/MindSPONGE/applications/proteinmpnn/examples/ab_pdb_example.sh b/MindSPONGE/applications/proteinmpnn/examples/ab_pdb_example.sh new file mode 100644 index 000000000..a5c738599 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/ab_pdb_example.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +python proteinmpnn_interface_design.py \ + -pdbdir examples/example_inputs/RFdiffusion \ + -outpdbdir examples/example_outputs/RFdiffusion diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_1.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_1.sh new file mode 100644 index 000000000..8d791699c --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_1.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +folder_with_pdbs="examples/example_inputs/PDB_monomers/" + +output_dir="examples/example_outputs/example_1_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" + +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --out_folder $output_dir \ + --num_seq_per_target 2 \ + --sampling_temp "0.1" \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_2.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_2.sh new file mode 100644 index 000000000..800fb07e4 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_2.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +folder_with_pdbs="examples/example_inputs/PDB_complexes/" + +output_dir="examples/example_outputs/example_2_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" +path_for_assigned_chains=$output_dir"/assigned_pdbs.jsonl" +chains_to_design="A B" + +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python helper_scripts/assign_fixed_chains.py --input_path=$path_for_parsed_chains --output_path=$path_for_assigned_chains --chain_list "$chains_to_design" + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --chain_id_jsonl $path_for_assigned_chains \ + --out_folder $output_dir \ + --num_seq_per_target 2 \ + --sampling_temp "0.1" \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_3.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_3.sh new file mode 100644 index 000000000..0e8394dfd --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_3.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +path_to_PDB="examples/example_inputs/PDB_complexes/3HTN.pdb" + +output_dir="examples/example_outputs/example_3_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + +chains_to_design="A B" + +python proteinmpnn_run.py \ + --pdb_path $path_to_PDB \ + --pdb_path_chains "$chains_to_design" \ + --out_folder $output_dir \ + --num_seq_per_target 2 \ + --sampling_temp "0.1" \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_3_score_only.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_3_score_only.sh new file mode 100644 index 000000000..78e1630c5 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_3_score_only.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +path_to_PDB="examples/example_inputs/PDB_complexes/3HTN.pdb" + +output_dir="examples/example_outputs/example_3_score_only_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + +chains_to_design="A B" + +python proteinmpnn_run.py \ + --pdb_path $path_to_PDB \ + --pdb_path_chains "$chains_to_design" \ + --out_folder $output_dir \ + --num_seq_per_target 10 \ + --sampling_temp "0.1" \ + --score_only 1 \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_3_score_only_from_fasta.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_3_score_only_from_fasta.sh new file mode 100644 index 000000000..bc8810e53 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_3_score_only_from_fasta.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +path_to_PDB="examples/example_inputs/PDB_complexes/3HTN.pdb" +path_to_fasta="examples/example_outputs/example_3_outputs/seqs/3HTN.fa" + +output_dir="examples/example_outputs/example_3_score_only_from_fasta_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + +chains_to_design="A B" + +python proteinmpnn_run.py \ + --path_to_fasta $path_to_fasta \ + --pdb_path $path_to_PDB \ + --pdb_path_chains "$chains_to_design" \ + --out_folder $output_dir \ + --num_seq_per_target 5 \ + --sampling_temp "0.1" \ + --score_only 1 \ + --seed 13 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_4.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_4.sh new file mode 100644 index 000000000..9f73f8ca6 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_4.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +folder_with_pdbs="examples/example_inputs/PDB_complexes/" + +output_dir="examples/example_outputs/example_4_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" +path_for_assigned_chains=$output_dir"/assigned_pdbs.jsonl" +path_for_fixed_positions=$output_dir"/fixed_pdbs.jsonl" +chains_to_design="A C" +#The first amino acid in the chain corresponds to 1 and not PDB residues index for now. +fixed_positions="1 2 3 4 5 6 7 8 23 25, 10 11 12 13 14 15 16 17 18 19 20 40" #fixing/not designing residues 1 2 3...25 in chain A and residues 10 11 12...40 in chain C + +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python helper_scripts/assign_fixed_chains.py --input_path=$path_for_parsed_chains --output_path=$path_for_assigned_chains --chain_list "$chains_to_design" + +python helper_scripts/make_fixed_positions_dict.py --input_path=$path_for_parsed_chains --output_path=$path_for_fixed_positions --chain_list "$chains_to_design" --position_list "$fixed_positions" + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --chain_id_jsonl $path_for_assigned_chains \ + --fixed_positions_jsonl $path_for_fixed_positions \ + --out_folder $output_dir \ + --num_seq_per_target 2 \ + --sampling_temp "0.1" \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_4_non_fixed.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_4_non_fixed.sh new file mode 100644 index 000000000..2ad1169d5 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_4_non_fixed.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +folder_with_pdbs="examples/example_inputs/PDB_complexes/" + +output_dir="examples/example_outputs/example_4_non_fixed_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" +path_for_assigned_chains=$output_dir"/assigned_pdbs.jsonl" +path_for_fixed_positions=$output_dir"/fixed_pdbs.jsonl" +chains_to_design="A C" +#The first amino acid in the chain corresponds to 1 and not PDB residues index for now. +design_only_positions="1 2 3 4 5 6 7 8 9 10, 3 4 5 6 7 8" #design only these residues; use flag --specify_non_fixed + +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python helper_scripts/assign_fixed_chains.py --input_path=$path_for_parsed_chains --output_path=$path_for_assigned_chains --chain_list "$chains_to_design" + +python helper_scripts/make_fixed_positions_dict.py --input_path=$path_for_parsed_chains --output_path=$path_for_fixed_positions --chain_list "$chains_to_design" --position_list "$design_only_positions" --specify_non_fixed + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --chain_id_jsonl $path_for_assigned_chains \ + --fixed_positions_jsonl $path_for_fixed_positions \ + --out_folder $output_dir \ + --num_seq_per_target 2 \ + --sampling_temp "0.1" \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_5.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_5.sh new file mode 100644 index 000000000..38dd9d5f3 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_5.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +folder_with_pdbs="examples/example_inputs/PDB_complexes/" + +output_dir="examples/example_outputs/example_5_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" +path_for_assigned_chains=$output_dir"/assigned_pdbs.jsonl" +path_for_fixed_positions=$output_dir"/fixed_pdbs.jsonl" +path_for_tied_positions=$output_dir"/tied_pdbs.jsonl" +chains_to_design="A C" +fixed_positions="9 10 11 12 13 14 15 16 17 18 19 20 21 22 23, 10 11 18 19 20 22" +tied_positions="1 2 3 4 5 6 7 8, 1 2 3 4 5 6 7 8" #two list must match in length; residue 1 in chain A and C will be sampled together; + +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python helper_scripts/assign_fixed_chains.py --input_path=$path_for_parsed_chains --output_path=$path_for_assigned_chains --chain_list "$chains_to_design" + +python helper_scripts/make_fixed_positions_dict.py --input_path=$path_for_parsed_chains --output_path=$path_for_fixed_positions --chain_list "$chains_to_design" --position_list "$fixed_positions" + +python helper_scripts/make_tied_positions_dict.py --input_path=$path_for_parsed_chains --output_path=$path_for_tied_positions --chain_list "$chains_to_design" --position_list "$tied_positions" + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --chain_id_jsonl $path_for_assigned_chains \ + --fixed_positions_jsonl $path_for_fixed_positions \ + --tied_positions_jsonl $path_for_tied_positions \ + --out_folder $output_dir \ + --num_seq_per_target 2 \ + --sampling_temp "0.1" \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_6.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_6.sh new file mode 100644 index 000000000..cbac7e81a --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_6.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +folder_with_pdbs="examples/example_inputs/PDB_homooligomers/" + +output_dir="examples/example_outputs/example_6_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" +path_for_tied_positions=$output_dir"/tied_pdbs.jsonl" + +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python helper_scripts/make_tied_positions_dict.py --input_path=$path_for_parsed_chains --output_path=$path_for_tied_positions --homooligomer 1 + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --tied_positions_jsonl $path_for_tied_positions \ + --out_folder $output_dir \ + --num_seq_per_target 2 \ + --sampling_temp "0.2" \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_7.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_7.sh new file mode 100644 index 000000000..d315b801f --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_7.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +folder_with_pdbs="examples/example_inputs/PDB_monomers/" + +output_dir="examples/example_outputs/example_7_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" + +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --out_folder $output_dir \ + --num_seq_per_target 1 \ + --sampling_temp "0.1" \ + --unconditional_probs_only 1 \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_8.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_8.sh new file mode 100644 index 000000000..0288c8e80 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_8.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +folder_with_pdbs="examples/example_inputs/PDB_monomers/" + +output_dir="examples/example_outputs/example_8_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + +path_for_bias=$output_dir"/bias_pdbs.jsonl" +#Adding global polar amino acid bias (Doug Tischer) +AA_list="D E H K N Q R S T W Y" +bias_list="1.39 1.39 1.39 1.39 1.39 1.39 1.39 1.39 1.39 1.39 1.39" +python helper_scripts/make_bias_AA.py --output_path=$path_for_bias --AA_list="$AA_list" --bias_list="$bias_list" + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --out_folder $output_dir \ + --bias_AA_jsonl $path_for_bias \ + --num_seq_per_target 2 \ + --sampling_temp "0.1" \ + --seed 37 \ + --batch_size 1 diff --git a/MindSPONGE/applications/proteinmpnn/examples/submit_example_pssm.sh b/MindSPONGE/applications/proteinmpnn/examples/submit_example_pssm.sh new file mode 100644 index 000000000..ed54762fe --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/examples/submit_example_pssm.sh @@ -0,0 +1,42 @@ +#!/bin/bash + + +#new_probabilities_using_PSSM = (1-pssm_multi*pssm_coef_gathered[:,None])*probs + pssm_multi*pssm_coef_gathered[:,None]*pssm_bias_gathered +#probs - predictions from MPNN +#pssm_bias_gathered - input PSSM bias (needs to be a probability distribution) +#pssm_multi - a number between 0.0 (no bias) and 1.0 (no MPNN) inputted via flag --pssm_multi; this is a global number equally applied to all the residues +#pssm_coef_gathered - a number between 0.0 (no bias) and 1.0 (no MPNN) inputted via ../helper_scripts/make_pssm_input_dict.py can be adjusted per residue level; i.e only apply PSSM bias to specific residues; or chains + + + +pssm_input_path="examples/example_inputs/PSSM_inputs" +folder_with_pdbs="examples/example_inputs/PDB_complexes/" + +output_dir="examples/example_outputs/example_pssm_outputs" +if [ ! -d $output_dir ] +then + mkdir -p $output_dir +fi + +path_for_parsed_chains=$output_dir"/parsed_pdbs.jsonl" +path_for_assigned_chains=$output_dir"/assigned_pdbs.jsonl" +pssm=$output_dir"/pssm.jsonl" +chains_to_design="A B" + +python helper_scripts/parse_multiple_chains.py --input_path=$folder_with_pdbs --output_path=$path_for_parsed_chains + +python helper_scripts/assign_fixed_chains.py --input_path=$path_for_parsed_chains --output_path=$path_for_assigned_chains --chain_list "$chains_to_design" + +python helper_scripts/make_pssm_input_dict.py --jsonl_input_path=$path_for_parsed_chains --PSSM_input_path=$pssm_input_path --output_path=$pssm + +python proteinmpnn_run.py \ + --jsonl_path $path_for_parsed_chains \ + --chain_id_jsonl $path_for_assigned_chains \ + --out_folder $output_dir \ + --num_seq_per_target 2 \ + --sampling_temp "0.1" \ + --seed 37 \ + --batch_size 1 \ + --pssm_jsonl $pssm \ + --pssm_multi 0.3 \ + --pssm_bias_flag 1 diff --git a/MindSPONGE/applications/proteinmpnn/helper_scripts/assign_fixed_chains.py b/MindSPONGE/applications/proteinmpnn/helper_scripts/assign_fixed_chains.py new file mode 100644 index 000000000..42958e3ad --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/helper_scripts/assign_fixed_chains.py @@ -0,0 +1,54 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Assign fixed chains for each pdb in the parsed PDBs folder. +""" + +import argparse +import json + +argparser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) +argparser.add_argument("--input_path", type=str, help="Path to the parsed PDBs") +argparser.add_argument("--output_path", type=str, help="Path to the output dictionary") +argparser.add_argument("--chain_list", type=str, default='', help="List of the chains that need to be designed") + +args = argparser.parse_args() + +with open(args.input_path, 'r', encoding='utf-8') as json_file: + json_list = list(json_file) + +global_designed_chain_list = [] +if args.chain_list != '': + global_designed_chain_list = [str(item) for item in args.chain_list.split()] +my_dict = {} +for json_str in json_list: + result = json.loads(json_str) + all_chain_list = [item[-1:] for item in list(result) if item[:9]=='seq_chain'] #['A','B', 'C',...] + if len(global_designed_chain_list) > 0: + designed_chain_list = global_designed_chain_list + else: + #manually specify, e.g. + designed_chain_list = ["A"] + fixed_chain_list = [letter for letter in all_chain_list if letter not in designed_chain_list] #fix/do not redesign these chains + my_dict[result['name']]= (designed_chain_list, fixed_chain_list) + +with open(args.output_path, 'w', encoding='utf-8') as f: + f.write(json.dumps(my_dict) + '\n') + +# Output looks like this: +# {"5TTA": [["A"], ["B"]], "3LIS": [["A"], ["B"]]} diff --git a/MindSPONGE/applications/proteinmpnn/helper_scripts/make_bias_AA.py b/MindSPONGE/applications/proteinmpnn/helper_scripts/make_bias_AA.py new file mode 100644 index 000000000..12a14b96f --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/helper_scripts/make_bias_AA.py @@ -0,0 +1,41 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Make a bias dictionary for the AAs to be biased. +""" + +import argparse +import json + +argparser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) +argparser.add_argument("--output_path", type=str, help="Path to the output dictionary") +argparser.add_argument("--AA_list", type=str, default='', help="List of AAs to be biased") +argparser.add_argument("--bias_list", type=str, default='', help="AA bias strengths") + +args = argparser.parse_args() + +bias_list = [float(item) for item in args.bias_list.split()] +AA_list = [str(item) for item in args.AA_list.split()] + +my_dict = dict(zip(AA_list, bias_list)) + +with open(args.output_path, 'w', encoding='utf-8') as f: + f.write(json.dumps(my_dict) + '\n') + +#e.g. output +#{"A": -0.01, "G": 0.02} diff --git a/MindSPONGE/applications/proteinmpnn/helper_scripts/make_fixed_positions_dict.py b/MindSPONGE/applications/proteinmpnn/helper_scripts/make_fixed_positions_dict.py new file mode 100644 index 000000000..41f63f389 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/helper_scripts/make_fixed_positions_dict.py @@ -0,0 +1,75 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Make a fixed positions dictionary for each pdb in the parsed PDBs folder. +""" + +import argparse +import json +import numpy as np + + +argparser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) +argparser.add_argument("--input_path", type=str, help="Path to the parsed PDBs") +argparser.add_argument("--output_path", type=str, help="Path to the output dictionary") +argparser.add_argument("--chain_list", type=str, default='', help="List of the chains that need to be fixed") +argparser.add_argument("--position_list", type=str, default='', + help="Position lists, e.g. 11 12 14 18, 1 2 3 4 for first chain and the second chain") +argparser.add_argument("--specify_non_fixed", action="store_true", default=False, + help="Allows specifying just residues that need to be designed (default: false)") + +args = argparser.parse_args() + +with open(args.input_path, 'r', encoding='utf-8') as json_file: + json_list = list(json_file) + +fixed_list = [[int(item) for item in one.split()] for one in args.position_list.split(",")] +global_designed_chain_list = [str(item) for item in args.chain_list.split()] +my_dict = {} + +if not args.specify_non_fixed: + for json_str in json_list: + result = json.loads(json_str) + all_chain_list = [item[-1:] for item in list(result) if item[:9]=='seq_chain'] + fixed_position_dict = {} + for i, chain in enumerate(global_designed_chain_list): + fixed_position_dict[chain] = fixed_list[i] + for chain in all_chain_list: + if chain not in global_designed_chain_list: + fixed_position_dict[chain] = [] + my_dict[result['name']] = fixed_position_dict +else: + for json_str in json_list: + result = json.loads(json_str) + all_chain_list = [item[-1:] for item in list(result) if item[:9]=='seq_chain'] + fixed_position_dict = {} + for chain in all_chain_list: + seq_length = len(result[f'seq_chain_{chain}']) + all_residue_list = (np.arange(seq_length)+1).tolist() + if chain not in global_designed_chain_list: + fixed_position_dict[chain] = all_residue_list + else: + idx = np.argwhere(np.array(global_designed_chain_list) == chain)[0][0] + fixed_position_dict[chain] = list(set(all_residue_list)-set(fixed_list[idx])) + my_dict[result['name']] = fixed_position_dict + +with open(args.output_path, 'w', encoding='utf-8') as f: + f.write(json.dumps(my_dict) + '\n') + +#e.g. output +#{"5TTA": {"A": [1, 2, 3, 7, 8, 9, 22, 25, 33], "B": []}, "3LIS": {"A": [], "B": []}} diff --git a/MindSPONGE/applications/proteinmpnn/helper_scripts/make_pssm_input_dict.py b/MindSPONGE/applications/proteinmpnn/helper_scripts/make_pssm_input_dict.py new file mode 100644 index 000000000..f67d46cc6 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/helper_scripts/make_pssm_input_dict.py @@ -0,0 +1,54 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Make a PSSM input dictionary for each pdb in the parsed PDBs folder. +""" + +import argparse +import json +import numpy as np + +argparser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + +argparser.add_argument("--PSSM_input_path", type=str, help="Path to PSSMs saved as npz files.") +argparser.add_argument("--jsonl_input_path", type=str, help="Path where to load .jsonl dictionary of parsed pdbs.") +argparser.add_argument("--output_path", type=str, help="Path where to save .jsonl dictionary with PSSM bias.") + +args = argparser.parse_args() + +with open(args.jsonl_input_path, 'r', encoding='utf-8') as json_file: + json_list = list(json_file) + +my_dict = {} +for json_str in json_list: + result = json.loads(json_str) + all_chain_list = [item[-1:] for item in list(result) if item[:9]=='seq_chain'] + path_to_PSSM = args.PSSM_input_path+"/"+result['name'] + ".npz" + print(path_to_PSSM) + pssm_input = np.load(path_to_PSSM) + pssm_dict = {} + for chain in all_chain_list: + pssm_dict[chain] = {} + pssm_dict[chain]['pssm_coef'] = pssm_input[chain+'_coef'].tolist() #[L] per position coefficient to trust PSSM; 0.0 - do not use it; 1.0 - just use PSSM only + pssm_dict[chain]['pssm_bias'] = pssm_input[chain+'_bias'].tolist() #[L,21] probability (sums up to 1.0 over alphabet of size 21) from PSSM + pssm_dict[chain]['pssm_log_odds'] = pssm_input[chain+'_odds'].tolist() #[L,21] log_odds ratios coming from PSSM; optional/not needed + my_dict[result['name']] = pssm_dict + +#Write output to: +with open(args.output_path, 'w', encoding='utf-8') as f: + f.write(json.dumps(my_dict) + '\n') diff --git a/MindSPONGE/applications/proteinmpnn/helper_scripts/make_tied_positions_dict.py b/MindSPONGE/applications/proteinmpnn/helper_scripts/make_tied_positions_dict.py new file mode 100644 index 000000000..03e48efb3 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/helper_scripts/make_tied_positions_dict.py @@ -0,0 +1,69 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Make a tied positions dictionary for each pdb in the parsed PDBs folder. +""" + +import argparse +import json + +argparser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) +argparser.add_argument("--input_path", type=str, help="Path to the parsed PDBs") +argparser.add_argument("--output_path", type=str, help="Path to the output dictionary") +argparser.add_argument("--chain_list", type=str, default='', help="List of the chains that need to be fixed") +argparser.add_argument("--position_list", type=str, default='', + help="Position lists, e.g. 11 12 14 18, 1 2 3 4 for first chain and the second chain") +argparser.add_argument("--homooligomer", type=int, default=0, help="If 0 do not use, if 1 then design homooligomer") + +args = argparser.parse_args() + +with open(args.input_path, 'r', encoding='utf-8') as json_file: + json_list = list(json_file) + +homooligomeric_state = args.homooligomer + +if homooligomeric_state == 0: + tied_list = [[int(item) for item in one.split()] for one in args.position_list.split(",")] + global_designed_chain_list = [str(item) for item in args.chain_list.split()] + my_dict = {} + for json_str in json_list: + result = json.loads(json_str) + all_chain_list = sorted([item[-1:] for item in list(result) if item[:9]=='seq_chain']) #A, B, C, ... + tied_positions_list = [] + for i, _ in enumerate(tied_list[0]): + temp_dict = {} + for j, chain in enumerate(global_designed_chain_list): + temp_dict[chain] = [tied_list[j][i]] #needs to be a list + tied_positions_list.append(temp_dict) + my_dict[result['name']] = tied_positions_list +else: + my_dict = {} + for json_str in json_list: + result = json.loads(json_str) + all_chain_list = sorted([item[-1:] for item in list(result) if item[:9]=='seq_chain']) #A, B, C, ... + tied_positions_list = [] + chain_length = len(result[f"seq_chain_{all_chain_list[0]}"]) + for i in range(1,chain_length+1): + temp_dict = {} + for j, chain in enumerate(all_chain_list): + temp_dict[chain] = [i] #needs to be a list + tied_positions_list.append(temp_dict) + my_dict[result['name']] = tied_positions_list + +with open(args.output_path, 'w', encoding='utf-8') as f: + f.write(json.dumps(my_dict) + '\n') diff --git a/MindSPONGE/applications/proteinmpnn/helper_scripts/parse_multiple_chains.py b/MindSPONGE/applications/proteinmpnn/helper_scripts/parse_multiple_chains.py new file mode 100644 index 000000000..13267bff9 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/helper_scripts/parse_multiple_chains.py @@ -0,0 +1,90 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Parse multiple chains in a pdb file. +""" + +import argparse +import json +import glob + +from proteinmpnn.util_protein_mpnn import parse_PDB_biounits, get_chain_alphabet + +argparser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + +argparser.add_argument("--input_path", type=str, help="Path to a folder with pdb files, e.g. /home/my_pdbs/") +argparser.add_argument("--output_path", type=str, help="Path where to save .jsonl dictionary of parsed pdbs") +argparser.add_argument("--ca_only", action="store_true", + default=False, help="parse a backbone-only structure (default: false)") + +args = argparser.parse_args() + +folder_with_pdbs_path = args.input_path +save_path = args.output_path +ca_only = args.ca_only + +pdb_dict_list = [] +c = 0 + +if folder_with_pdbs_path[-1]!='/': + folder_with_pdbs_path = folder_with_pdbs_path+'/' + + +chain_alphabet = get_chain_alphabet() + +biounit_names = glob.glob(folder_with_pdbs_path+'*.pdb') +for biounit in biounit_names: + my_dict = {} + s = 0 + concat_seq = '' + concat_N = [] + concat_CA = [] + concat_C = [] + concat_O = [] + concat_mask = [] + coords_dict = {} + for letter in chain_alphabet: + if ca_only: + sidechain_atoms = ['CA'] + else: + sidechain_atoms = ['N', 'CA', 'C', 'O'] + xyz, seq = parse_PDB_biounits(biounit, atoms=sidechain_atoms, chain=letter) + if not isinstance(xyz, str): + concat_seq += seq[0] + my_dict['seq_chain_'+letter]=seq[0] + coords_dict_chain = {} + if ca_only: + coords_dict_chain['CA_chain_'+letter]=xyz.tolist() + else: + coords_dict_chain['N_chain_' + letter] = xyz[:, 0, :].tolist() + coords_dict_chain['CA_chain_' + letter] = xyz[:, 1, :].tolist() + coords_dict_chain['C_chain_' + letter] = xyz[:, 2, :].tolist() + coords_dict_chain['O_chain_' + letter] = xyz[:, 3, :].tolist() + my_dict['coords_chain_'+letter]=coords_dict_chain + s += 1 + fi = biounit.rfind("/") + my_dict['name']=biounit[(fi+1):-4] + my_dict['num_of_chains'] = s + my_dict['seq'] = concat_seq + if s < len(chain_alphabet): + pdb_dict_list.append(my_dict) + c+=1 + +with open(save_path, 'w', encoding='utf-8') as f: + for entry in pdb_dict_list: + f.write(json.dumps(entry) + '\n') diff --git a/MindSPONGE/applications/proteinmpnn/img/github_fig.png b/MindSPONGE/applications/proteinmpnn/img/github_fig.png new file mode 100644 index 0000000000000000000000000000000000000000..98c1916dcf8b984c0b3be68fe209efc19b2ecdd0 GIT binary patch literal 111513 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": + if len(header) == limit: + break + header.append(line[1:]) + sequence.append([]) + else: + if omit: + line = [item for item in line if item not in omit] + line = "".join(line) + line = "".join(line) + sequence[-1].append(line) + sequence = ["".join(seq) for seq in sequence] + return np.array(header), np.array(sequence) + + +def _scores(S, log_probs, mask): + """ + Calculate negative log probabilities. + + Args: + S (ms.Tensor): Ground truth sequence indices. + log_probs (ms.Tensor): Log probabilities of predicted sequences. + mask (ms.Tensor): Mask to apply to the loss calculation. + + Returns: + ms.Tensor: Negative log probabilities for each sequence. + """ + criterion = nn.NLLLoss(reduction="none") + loss = criterion( + log_probs.contiguous().view(-1, log_probs.shape[-1]), S.contiguous().view(-1) + ).view(S.shape) + scores = ms.mint.sum(loss * mask, dim=-1) / ms.mint.sum(mask, dim=-1) + return scores + + +def _S_to_seq(S, mask): + """ + Convert sequence indices to amino acid sequences. + + Args: + S (ms.Tensor): Sequence indices. + mask (ms.Tensor): Mask to apply to the sequence. + + Returns: + str: Amino acid sequence. + """ + alphabet = "ACDEFGHIKLMNPQRSTVWYX" + seq = "".join([alphabet[c] for c, m in zip(S.tolist(), mask.tolist()) if m > 0]) + return seq + +def parse_PDB(path_to_pdb, input_chain_list=None, ca_only=False): + """ + Parse a PDB file and extract coordinates and sequences for each chain. + + Args: + path_to_pdb (str): Path to the PDB file. + input_chain_list (list, optional): List of chains to extract. Defaults to None. + ca_only (bool, optional): Whether to extract only CA atoms. Defaults to False. + + Returns: + list: A list of dictionaries, each containing coordinates and sequence for a chain. + """ + from .util_protein_mpnn import parse_PDB_biounits, get_chain_alphabet + c = 0 + pdb_dict_list = [] + chain_alphabet = get_chain_alphabet(input_chain_list) + + biounit_names = [path_to_pdb] + for biounit in biounit_names: + my_dict = {} + s = 0 + concat_seq = "" + for letter in chain_alphabet: + if ca_only: + sidechain_atoms = ["CA"] + else: + sidechain_atoms = ["N", "CA", "C", "O"] + xyz, seq = parse_PDB_biounits(biounit, atoms=sidechain_atoms, chain=letter) + if not isinstance(xyz, str): + concat_seq += seq[0] + my_dict["seq_chain_" + letter] = seq[0] + coords_dict_chain = {} + if ca_only: + coords_dict_chain["CA_chain_" + letter] = xyz.tolist() + else: + coords_dict_chain["N_chain_" + letter] = xyz[:, 0, :].tolist() + coords_dict_chain["CA_chain_" + letter] = xyz[:, 1, :].tolist() + coords_dict_chain["C_chain_" + letter] = xyz[:, 2, :].tolist() + coords_dict_chain["O_chain_" + letter] = xyz[:, 3, :].tolist() + my_dict["coords_chain_" + letter] = coords_dict_chain + s += 1 + fi = biounit.rfind("/") + my_dict["name"] = biounit[(fi + 1) : -4] + my_dict["num_of_chains"] = s + my_dict["seq"] = concat_seq + if s <= len(chain_alphabet): + pdb_dict_list.append(my_dict) + c += 1 + return pdb_dict_list + + +def tied_featurize( + batch, + chain_dict, + fixed_position_dict=None, + omit_AA_dict=None, + tied_positions_dict=None, + pssm_dict=None, + bias_by_res_dict=None, + ca_only=False, +): + """ + Featurize a batch of sequences with tied positions. + + Args: + batch (list): A list of dictionaries, each containing coordinates and sequence for a chain. + chain_dict (dict): A dictionary mapping biounit names to tuples of masked and visible chains. + fixed_position_dict (dict, optional): A dictionary mapping fixed positions to amino acids. Defaults to None. + omit_AA_dict (dict, optional): A dictionary mapping positions to omitted amino acids. Defaults to None. + tied_positions_dict (dict, optional): A dictionary mapping tied positions to their groups. Defaults to None. + pssm_dict (dict, optional): A dictionary mapping positions to PSSM values. Defaults to None. + bias_by_res_dict (dict, optional): A dictionary mapping positions to bias values. Defaults to None. + ca_only (bool, optional): Whether to extract only CA atoms. Defaults to False. + + Returns: + tuple: A tuple containing various featurized arrays for the batch. + """ + alphabet = "ACDEFGHIKLMNPQRSTVWYX" + B = len(batch) + lengths = np.array( + [len(b["seq"]) for b in batch], dtype=np.int32 + ) # sum of chain seq lengths + L_max = max(len(b["seq"]) for b in batch) + if ca_only: + X = np.zeros([B, L_max, 1, 3]) + else: + X = np.zeros([B, L_max, 4, 3]) + residue_idx = -100 * np.ones([B, L_max], dtype=np.int32) + chain_M = np.zeros( + [B, L_max], dtype=np.int32 + ) # 1.0 for the bits that need to be predicted + pssm_coef_all = np.zeros( + [B, L_max], dtype=np.float32 + ) # 1.0 for the bits that need to be predicted + pssm_bias_all = np.zeros( + [B, L_max, 21], dtype=np.float32 + ) # 1.0 for the bits that need to be predicted + pssm_log_odds_all = 10000.0 * np.ones( + [B, L_max, 21], dtype=np.float32 + ) # 1.0 for the bits that need to be predicted + chain_M_pos = np.zeros( + [B, L_max], dtype=np.int32 + ) # 1.0 for the bits that need to be predicted + bias_by_res_all = np.zeros([B, L_max, 21], dtype=np.float32) + chain_encoding_all = np.zeros( + [B, L_max], dtype=np.int32 + ) # 1.0 for the bits that need to be predicted + S = np.zeros([B, L_max], dtype=np.int32) + omit_AA_mask = np.zeros([B, L_max, len(alphabet)], dtype=np.int32) + # Build the batch + letter_list_list = [] + visible_list_list = [] + masked_list_list = [] + masked_chain_length_list_list = [] + tied_pos_list_of_lists_list = [] + for i, b in enumerate(batch): + if chain_dict is not None: + masked_chains, visible_chains = chain_dict[ + b["name"] + ] # masked_chains a list of chain letters to predict [A, D, F] + else: + masked_chains = [item[-1:] for item in list(b) if item[:10] == "seq_chain_"] + visible_chains = [] + masked_chains.sort() # sort masked_chains + visible_chains.sort() # sort visible_chains + all_chains = masked_chains + visible_chains + for i, b in enumerate(batch): + x_chain_list = [] + chain_mask_list = [] + chain_seq_list = [] + chain_encoding_list = [] + c = 1 + letter_list = [] + global_idx_start_list = [0] + visible_list = [] + masked_list = [] + masked_chain_length_list = [] + fixed_position_mask_list = [] + omit_AA_mask_list = [] + pssm_coef_list = [] + pssm_bias_list = [] + pssm_log_odds_list = [] + bias_by_res_list = [] + l0 = 0 + l1 = 0 + for _, letter in enumerate(all_chains): + if letter in visible_chains: + letter_list.append(letter) + visible_list.append(letter) + chain_seq = b[f"seq_chain_{letter}"] + chain_seq = "".join([a if a != "-" else "X" for a in chain_seq]) + chain_length = len(chain_seq) + global_idx_start_list.append(global_idx_start_list[-1] + chain_length) + chain_coords = b[f"coords_chain_{letter}"] # this is a dictionary + chain_mask = np.zeros(chain_length) # 0.0 for visible chains + if ca_only: + x_chain = np.array( + chain_coords[f"CA_chain_{letter}"] + ) # [chain_lenght,1,3] #CA_diff + if len(x_chain.shape) == 2: + x_chain = x_chain[:, None, :] + else: + x_chain = np.stack( + [ + chain_coords[c] + for c in [ + f"N_chain_{letter}", + f"CA_chain_{letter}", + f"C_chain_{letter}", + f"O_chain_{letter}", + ] + ], + 1, + ) # [chain_lenght,4,3] + x_chain_list.append(x_chain) + chain_mask_list.append(chain_mask) + chain_seq_list.append(chain_seq) + chain_encoding_list.append(c * np.ones(np.array(chain_mask).shape[0])) + l1 += chain_length + residue_idx[i, l0:l1] = 100 * (c - 1) + np.arange(l0, l1) + l0 += chain_length + c += 1 + fixed_position_mask = np.ones(chain_length) + fixed_position_mask_list.append(fixed_position_mask) + omit_AA_mask_temp = np.zeros([chain_length, len(alphabet)], np.int32) + omit_AA_mask_list.append(omit_AA_mask_temp) + pssm_coef = np.zeros(chain_length) + pssm_bias = np.zeros([chain_length, 21]) + pssm_log_odds = 10000.0 * np.ones([chain_length, 21]) + pssm_coef_list.append(pssm_coef) + pssm_bias_list.append(pssm_bias) + pssm_log_odds_list.append(pssm_log_odds) + bias_by_res_list.append(np.zeros([chain_length, 21])) + if letter in masked_chains: + masked_list.append(letter) + letter_list.append(letter) + chain_seq = b[f"seq_chain_{letter}"] + chain_seq = "".join([a if a != "-" else "X" for a in chain_seq]) + chain_length = len(chain_seq) + global_idx_start_list.append(global_idx_start_list[-1] + chain_length) + masked_chain_length_list.append(chain_length) + chain_coords = b[f"coords_chain_{letter}"] # this is a dictionary + chain_mask = np.ones(chain_length) # 1.0 for masked + if ca_only: + x_chain = np.array( + chain_coords[f"CA_chain_{letter}"] + ) # [chain_lenght,1,3] #CA_diff + if len(x_chain.shape) == 2: + x_chain = x_chain[:, None, :] + else: + x_chain = np.stack( + [ + chain_coords[c] + for c in [ + f"N_chain_{letter}", + f"CA_chain_{letter}", + f"C_chain_{letter}", + f"O_chain_{letter}", + ] + ], + 1, + ) # [chain_lenght,4,3] + x_chain_list.append(x_chain) + chain_mask_list.append(chain_mask) + chain_seq_list.append(chain_seq) + chain_encoding_list.append(c * np.ones(np.array(chain_mask).shape[0])) + l1 += chain_length + residue_idx[i, l0:l1] = 100 * (c - 1) + np.arange(l0, l1) + l0 += chain_length + c += 1 + fixed_position_mask = np.ones(chain_length) + if fixed_position_dict is not None: + fixed_pos_list = fixed_position_dict[b["name"]][letter] + if fixed_pos_list: + fixed_position_mask[np.array(fixed_pos_list) - 1] = 0.0 + fixed_position_mask_list.append(fixed_position_mask) + omit_AA_mask_temp = np.zeros([chain_length, len(alphabet)], np.int32) + if omit_AA_dict is not None: + for item in omit_AA_dict[b["name"]][letter]: + idx_AA = np.array(item[0]) - 1 + AA_idx = np.array( + [ + np.argwhere(np.array(list(alphabet)) == AA)[0][0] + for AA in item[1] + ] + ).repeat(idx_AA.shape[0]) + idx_ = np.array([[a, b] for a in idx_AA for b in AA_idx]) + omit_AA_mask_temp[idx_[:, 0], idx_[:, 1]] = 1 + omit_AA_mask_list.append(omit_AA_mask_temp) + pssm_coef = np.zeros(chain_length) + pssm_bias = np.zeros([chain_length, 21]) + pssm_log_odds = 10000.0 * np.ones([chain_length, 21]) + if pssm_dict: + if pssm_dict[b["name"]][letter]: + pssm_coef = pssm_dict[b["name"]][letter]["pssm_coef"] + pssm_bias = pssm_dict[b["name"]][letter]["pssm_bias"] + pssm_log_odds = pssm_dict[b["name"]][letter]["pssm_log_odds"] + pssm_coef_list.append(pssm_coef) + pssm_bias_list.append(pssm_bias) + pssm_log_odds_list.append(pssm_log_odds) + if bias_by_res_dict: + bias_by_res_list.append(bias_by_res_dict[b["name"]][letter]) + else: + bias_by_res_list.append(np.zeros([chain_length, 21])) + + letter_list_np = np.array(letter_list) + tied_pos_list_of_lists = [] + tied_beta = np.ones(L_max) + if tied_positions_dict is not None: + tied_pos_list = tied_positions_dict[b["name"]] + if tied_pos_list: + for tied_item in tied_pos_list: + one_list = [] + for k, v in tied_item.items(): + start_idx = global_idx_start_list[ + np.argwhere(letter_list_np == k)[0][0] + ] + if isinstance(v[0], list): + for v_count in range(len(v[0])): + one_list.append( + start_idx + v[0][v_count] - 1 + ) # make 0 to be the first + tied_beta[start_idx + v[0][v_count] - 1] = v[1][v_count] + else: + for v_ in v: + one_list.append( + start_idx + v_ - 1 + ) # make 0 to be the first + tied_pos_list_of_lists.append(one_list) + tied_pos_list_of_lists_list.append(tied_pos_list_of_lists) + + x = np.concatenate(x_chain_list, 0) # [L, 4, 3] + all_sequence = "".join(chain_seq_list) + m = np.concatenate( + chain_mask_list, 0 + ) # [L,], 1.0 for places that need to be predicted + chain_encoding = np.concatenate(chain_encoding_list, 0) + m_pos = np.concatenate( + fixed_position_mask_list, 0 + ) # [L,], 1.0 for places that need to be predicted + + pssm_coef_ = np.concatenate( + pssm_coef_list, 0 + ) # [L,], 1.0 for places that need to be predicted + pssm_bias_ = np.concatenate( + pssm_bias_list, 0 + ) # [L,], 1.0 for places that need to be predicted + pssm_log_odds_ = np.concatenate( + pssm_log_odds_list, 0 + ) # [L,], 1.0 for places that need to be predicted + + bias_by_res_ = np.concatenate( + bias_by_res_list, 0 + ) # [L,21], 0.0 for places where AA frequencies don't need to be tweaked + + l = len(all_sequence) + x_pad = np.pad( + x, [[0, L_max - l], [0, 0], [0, 0]], "constant", constant_values=(np.nan,) + ) + X[i, :, :, :] = x_pad + + m_pad = np.pad(m, [[0, L_max - l]], "constant", constant_values=(0.0,)) + m_pos_pad = np.pad(m_pos, [[0, L_max - l]], "constant", constant_values=(0.0,)) + omit_AA_mask_pad = np.pad( + np.concatenate(omit_AA_mask_list, 0), + [[0, L_max - l]], + "constant", + constant_values=(0.0,), + ) + chain_M[i, :] = m_pad + chain_M_pos[i, :] = m_pos_pad + omit_AA_mask[i,] = omit_AA_mask_pad + + chain_encoding_pad = np.pad( + chain_encoding, [[0, L_max - l]], "constant", constant_values=(0.0,) + ) + chain_encoding_all[i, :] = chain_encoding_pad + + pssm_coef_pad = np.pad( + pssm_coef_, [[0, L_max - l]], "constant", constant_values=(0.0,) + ) + pssm_bias_pad = np.pad( + pssm_bias_, [[0, L_max - l], [0, 0]], "constant", constant_values=(0.0,) + ) + pssm_log_odds_pad = np.pad( + pssm_log_odds_, [[0, L_max - l], [0, 0]], "constant", constant_values=(0.0,) + ) + + pssm_coef_all[i, :] = pssm_coef_pad + pssm_bias_all[i, :] = pssm_bias_pad + pssm_log_odds_all[i, :] = pssm_log_odds_pad + + bias_by_res_pad = np.pad( + bias_by_res_, [[0, L_max - l], [0, 0]], "constant", constant_values=(0.0,) + ) + bias_by_res_all[i, :] = bias_by_res_pad + + # Convert to labels + indices = np.asarray([alphabet.index(a) for a in all_sequence], dtype=np.int32) + S[i, :l] = indices + letter_list_list.append(letter_list) + visible_list_list.append(visible_list) + masked_list_list.append(masked_list) + masked_chain_length_list_list.append(masked_chain_length_list) + + isnan = np.isnan(X) + mask = np.isfinite(np.sum(X, (2, 3))).astype(np.float32) + X[isnan] = 0.0 + + # Conversion + pssm_coef_all = ms.from_numpy(pssm_coef_all).to(dtype=ms.float32) + pssm_bias_all = ms.from_numpy(pssm_bias_all).to(dtype=ms.float32) + pssm_log_odds_all = ms.from_numpy(pssm_log_odds_all).to(dtype=ms.float32) + + tied_beta = ms.from_numpy(tied_beta).to(dtype=ms.float32) + + jumps = ((residue_idx[:, 1:] - residue_idx[:, :-1]) == 1).astype(np.float32) + bias_by_res_all = ms.from_numpy(bias_by_res_all).to(dtype=ms.float32) + phi_mask = np.pad(jumps, [[0, 0], [1, 0]]) + psi_mask = np.pad(jumps, [[0, 0], [0, 1]]) + omega_mask = np.pad(jumps, [[0, 0], [0, 1]]) + dihedral_mask = np.concatenate( + [phi_mask[:, :, None], psi_mask[:, :, None], omega_mask[:, :, None]], -1 + ) # [B,L,3] + dihedral_mask = ms.from_numpy(dihedral_mask).to(dtype=ms.float32) + residue_idx = ms.from_numpy(residue_idx).to(dtype=ms.int64) + S = ms.from_numpy(S).to(dtype=ms.int64) + X = ms.from_numpy(X).to(dtype=ms.float32) + mask = ms.from_numpy(mask).to(dtype=ms.float32) + chain_M = ms.from_numpy(chain_M).to(dtype=ms.float32) + chain_M_pos = ms.from_numpy(chain_M_pos).to(dtype=ms.float32) + omit_AA_mask = ms.from_numpy(omit_AA_mask).to(dtype=ms.float32) + chain_encoding_all = ms.from_numpy(chain_encoding_all).to(dtype=ms.int64) + if ca_only: + X_out = X[:, :, 0] + else: + X_out = X + return ( + X_out, + S, + mask, + lengths, + chain_M, + chain_encoding_all, + letter_list_list, + visible_list_list, + masked_list_list, + masked_chain_length_list_list, + chain_M_pos, + omit_AA_mask, + residue_idx, + dihedral_mask, + tied_pos_list_of_lists_list, + pssm_coef_all, + pssm_bias_all, + pssm_log_odds_all, + bias_by_res_all, + tied_beta, + ) + + +def loss_nll(S, log_probs, mask): + """ + Negative log probabilities. + + Args: + S (ms.Tensor): Ground truth labels. + log_probs (ms.Tensor): Log probabilities. + mask (ms.Tensor): Mask tensor. + + Returns: + tuple: A tuple containing the loss tensor and the average loss. + """ + criterion = nn.NLLLoss(reduction="none") + loss = criterion( + log_probs.contiguous().view(-1, log_probs.shape[-1]), S.contiguous().view(-1) + ).view(S.shape) + loss_av = ms.mint.sum(loss * mask) / ms.mint.sum(mask) + return loss, loss_av + + +def loss_smoothed(S, log_probs, mask, weight=0.1): + """ + Smoothed negative log probabilities. + + Args: + S (ms.Tensor): Ground truth labels. + log_probs (ms.Tensor): Log probabilities. + mask (ms.Tensor): Mask tensor. + weight (float, optional): Label smoothing weight. Defaults to 0.1. + + Returns: + tuple: A tuple containing the loss tensor and the average loss. + """ + S_onehot = ms.mint.nn.functional.one_hot(S, 21).float() + + # Label smoothing + S_onehot = S_onehot + weight / float(S_onehot.shape[-1]) + S_onehot = S_onehot / S_onehot.sum(-1, keepdim=True) + + loss = -(S_onehot * log_probs).sum(-1) + loss_av = ms.mint.sum(loss * mask) / ms.mint.sum(mask) + return loss, loss_av + + +class StructureDataset: + """ + Dataset for protein structures. + + Args: + jsonl_file (str): Path to the JSONL file containing the structure data. + verbose (bool, optional): Whether to print verbose output. Defaults to True. + truncate (int, optional): Number of entries to truncate the dataset. Defaults to None. + max_length (int, optional): Maximum length of sequences to include in the dataset. Defaults to 100. + alphabet (str, optional): Alphabet of allowed characters in sequences. Defaults to "ACDEFGHIKLMNPQRSTVWYX-". + """ + def __init__( + self, + jsonl_file, + verbose=True, + truncate=None, + max_length=100, + alphabet="ACDEFGHIKLMNPQRSTVWYX-", + ): + alphabet_set = set(alphabet) + discard_count = {"bad_chars": 0, "too_long": 0, "bad_seq_length": 0} + + with open(jsonl_file, 'r', encoding='utf-8') as f: + self.data = [] + + lines = f.readlines() + start = time.time() + for i, line in enumerate(lines): + entry = json.loads(line) + seq = entry["seq"] + name = entry["name"] + + # Check if in alphabet + bad_chars = set(seq).difference(alphabet_set) + if len(bad_chars) == 0: + if len(entry["seq"]) <= max_length: + self.data.append(entry) + else: + discard_count["too_long"] += 1 + else: + if verbose: + print(name, bad_chars, entry["seq"]) + discard_count["bad_chars"] += 1 + + # Truncate early + if truncate is not None and len(self.data) == truncate: + return + + if verbose and (i + 1) % 1000 == 0: + elapsed = time.time() - start + print( + f"{len(self.data)} entries ({i + 1} loaded) in {elapsed:.1f} s" + ) + if verbose: + print("discarded", discard_count) + + def __len__(self): + return len(self.data) + + def __getitem__(self, idx): + return self.data[idx] + + +class StructureDatasetPDB: + """ + Dataset for protein structures from PDB files. + + Args: + pdb_dict_list (list): List of dictionaries containing PDB structure data. + verbose (bool, optional): Whether to print verbose output. Defaults to True. + truncate (int, optional): Number of entries to truncate the dataset. Defaults to None. + max_length (int, optional): Maximum length of sequences to include in the dataset. Defaults to 100. + alphabet (str, optional): Alphabet of allowed characters in sequences. Defaults to "ACDEFGHIKLMNPQRSTVWYX-". + """ + def __init__( + self, + pdb_dict_list, + truncate=None, + max_length=100, + alphabet="ACDEFGHIKLMNPQRSTVWYX-", + ): + alphabet_set = set(alphabet) + discard_count = {"bad_chars": 0, "too_long": 0, "bad_seq_length": 0} + + self.data = [] + + for _, entry in enumerate(pdb_dict_list): + seq = entry["seq"] + + bad_chars = set(seq).difference(alphabet_set) + if len(bad_chars) == 0: + if len(entry["seq"]) <= max_length: + self.data.append(entry) + else: + discard_count["too_long"] += 1 + else: + discard_count["bad_chars"] += 1 + + # Truncate early + if truncate is not None and len(self.data) == truncate: + return + + def __len__(self): + return len(self.data) + + def __getitem__(self, idx): + return self.data[idx] + + +# The following gather functions +def gather_edges(edges, neighbor_idx): + """ + Gather edge features from a tensor of edge features. + + Args: + edges (Tensor): Edge features of shape [B,N,N,C]. + neighbor_idx (Tensor): Neighbor indices of shape [B,N,K]. + + Returns: + Tensor: Gathered edge features of shape [B,N,K,C]. + """ + # Features [B,N,N,C] at Neighbor indices [B,N,K] => Neighbor features [B,N,K,C] + neighbors = neighbor_idx.unsqueeze(-1).expand(-1, -1, -1, edges.shape[-1]) + edge_features = ms.mint.gather(edges, 2, neighbors) + return edge_features + + +def gather_nodes(nodes, neighbor_idx): + """ + Gather node features from a tensor of node features. + + Args: + nodes (Tensor): Node features of shape [B,N,C]. + neighbor_idx (Tensor): Neighbor indices of shape [B,N,K]. + + Returns: + Tensor: Gathered node features of shape [B,N,K,C]. + """ + # Features [B,N,C] at Neighbor indices [B,N,K] => [B,N,K,C] + # Flatten and expand indices per batch [B,N,K] => [B,NK] => [B,NK,C] + neighbors_flat = neighbor_idx.view((neighbor_idx.shape[0], -1)) + neighbors_flat = neighbors_flat.unsqueeze(-1).expand(-1, -1, nodes.shape[2]) + # Gather and re-pack + neighbor_features = ms.mint.gather(nodes, 1, neighbors_flat) + neighbor_features = neighbor_features.view(list(neighbor_idx.shape)[:3] + [-1]) + return neighbor_features + + +def gather_nodes_t(nodes, neighbor_idx): + """ + Gather node features from a tensor of node features. + + Args: + nodes (Tensor): Node features of shape [B,N,C]. + neighbor_idx (Tensor): Neighbor indices of shape [B,K]. + + Returns: + Tensor: Gathered node features of shape [B,K,C]. + """ + # Features [B,N,C] at Neighbor index [B,K] => Neighbor features[B,K,C] + idx_flat = neighbor_idx.unsqueeze(-1).expand(-1, -1, nodes.shape[2]) + neighbor_features = ms.mint.gather(nodes, 1, idx_flat) + return neighbor_features + + +def cat_neighbors_nodes(h_nodes, h_neighbors, E_idx): + """ + Concatenate node features with neighbor features. + + Args: + h_nodes (Tensor): Node features of shape [B,N,C]. + h_neighbors (Tensor): Neighbor features of shape [B,N,K,C]. + E_idx (Tensor): Edge indices of shape [B,N,K]. + + Returns: + Tensor: Concatenated features of shape [B,N,K,2C]. + """ + h_nodes = gather_nodes(h_nodes, E_idx) + h_nn = ms.mint.cat([h_neighbors, h_nodes], -1) + return h_nn + + +class EncLayer(nn.Cell): + """ + Encoder layer for the ProteinMPNN model. + + Args: + num_hidden (int): Number of hidden units. + num_in (int): Number of input units. + dropout (float, optional): Dropout probability. Defaults to 0.1. + num_heads (int, optional): Number of attention heads. Defaults to None. + scale (float, optional): Scaling factor for attention scores. Defaults to 30. + """ + def __init__(self, num_hidden, num_in, dropout=0.1, scale=30): + super().__init__() + self.num_hidden = num_hidden + self.num_in = num_in + self.scale = scale + self.dropout1 = nn.Dropout(p=dropout) + self.dropout2 = nn.Dropout(p=dropout) + self.dropout3 = nn.Dropout(p=dropout) + self.norm1 = nn.LayerNorm((num_hidden,), epsilon=1e-5) + self.norm2 = nn.LayerNorm((num_hidden,), epsilon=1e-5) + self.norm3 = nn.LayerNorm((num_hidden,), epsilon=1e-5) + + self.W1 = nn.Linear(num_hidden + num_in, num_hidden, bias=True) + self.W2 = nn.Linear(num_hidden, num_hidden, bias=True) + self.W3 = nn.Linear(num_hidden, num_hidden, bias=True) + self.W11 = nn.Linear(num_hidden + num_in, num_hidden, bias=True) + self.W12 = nn.Linear(num_hidden, num_hidden, bias=True) + self.W13 = nn.Linear(num_hidden, num_hidden, bias=True) + self.act = nn.GELU() + self.dense = PositionWiseFeedForward(num_hidden, num_hidden * 4) + + def construct(self, h_V, h_E, E_idx, mask_V=None, mask_attend=None): + """Parallel computation of full transformer layer""" + + h_EV = cat_neighbors_nodes(h_V, h_E, E_idx) + h_V_expand = h_V.unsqueeze(-2).expand(-1, -1, h_EV.shape[-2], -1) + h_EV = ms.mint.cat([h_V_expand, h_EV], -1) + h_message = self.W3(self.act(self.W2(self.act(self.W1(h_EV))))) + if mask_attend is not None: + h_message = mask_attend.unsqueeze(-1) * h_message + dh = ms.mint.sum(h_message, -2) / self.scale + h_V = self.norm1(h_V + self.dropout1(dh)) + + dh = self.dense(h_V) + h_V = self.norm2(h_V + self.dropout2(dh)) + if mask_V is not None: + mask_V = mask_V.unsqueeze(-1) + h_V = mask_V * h_V + + h_EV = cat_neighbors_nodes(h_V, h_E, E_idx) + h_V_expand = h_V.unsqueeze(-2).expand(-1, -1, h_EV.shape[-2], -1) + h_EV = ms.mint.cat([h_V_expand, h_EV], -1) + h_message = self.W13(self.act(self.W12(self.act(self.W11(h_EV))))) + h_E = self.norm3(h_E + self.dropout3(h_message)) + return h_V, h_E + + +class DecLayer(nn.Cell): + """ + Decoder layer for the ProteinMPNN model. + + Args: + num_hidden (int): Number of hidden units. + num_in (int): Number of input units. + dropout (float, optional): Dropout probability. Defaults to 0.1. + num_heads (int, optional): Number of attention heads. Defaults to None. + scale (float, optional): Scaling factor for attention scores. Defaults to 30. + """ + def __init__(self, num_hidden, num_in, dropout=0.1, scale=30): + super().__init__() + self.num_hidden = num_hidden + self.num_in = num_in + self.scale = scale + self.dropout1 = nn.Dropout(p=dropout) + self.dropout2 = nn.Dropout(p=dropout) + self.norm1 = nn.LayerNorm((num_hidden,), epsilon=1e-5) + self.norm2 = nn.LayerNorm((num_hidden,), epsilon=1e-5) + + self.W1 = nn.Linear(num_hidden + num_in, num_hidden, bias=True) + self.W2 = nn.Linear(num_hidden, num_hidden, bias=True) + self.W3 = nn.Linear(num_hidden, num_hidden, bias=True) + self.act = nn.GELU() + self.dense = PositionWiseFeedForward(num_hidden, num_hidden * 4) + + def construct(self, h_V, h_E, mask_V=None, mask_attend=None): + """Parallel computation of full transformer layer""" + + # Concatenate h_V_i to h_E_ij + h_V_expand = h_V.unsqueeze(-2).expand(-1, -1, h_E.shape[-2], -1) + h_EV = ms.mint.cat([h_V_expand, h_E], -1) + + h_message = self.W3(self.act(self.W2(self.act(self.W1(h_EV))))) + if mask_attend is not None: + h_message = mask_attend.unsqueeze(-1) * h_message + dh = ms.mint.sum(h_message, -2) / self.scale + + h_V = self.norm1(h_V + self.dropout1(dh)) + + # Position-wise feedforward + dh = self.dense(h_V) + h_V = self.norm2(h_V + self.dropout2(dh)) + + if mask_V is not None: + mask_V = mask_V.unsqueeze(-1) + h_V = mask_V * h_V + return h_V + + +class PositionWiseFeedForward(nn.Cell): + """ + Position-wise feedforward layer. + + Args: + num_hidden (int): Number of hidden units. + num_ff (int): Number of feedforward units. + """ + def __init__(self, num_hidden, num_ff): + super().__init__() + self.W_in = nn.Linear(num_hidden, num_ff, bias=True) + self.W_out = nn.Linear(num_ff, num_hidden, bias=True) + self.act = nn.GELU() + + def construct(self, h_V): + h = self.act(self.W_in(h_V)) + h = self.W_out(h) + return h + + +class PositionalEncodings(nn.Cell): + """ + Positional encodings for relative positions. + + Args: + num_embeddings (int): Number of embeddings. + max_relative_feature (int, optional): Maximum relative feature. Defaults to 32. + """ + def __init__(self, num_embeddings, max_relative_feature=32): + super().__init__() + self.num_embeddings = num_embeddings + self.max_relative_feature = max_relative_feature + self.linear = nn.Linear(2 * max_relative_feature + 1 + 1, num_embeddings) + + def construct(self, offset, mask): + d = ms.mint.clip( + offset + self.max_relative_feature, 0, 2 * self.max_relative_feature + ) * mask + (1 - mask) * (2 * self.max_relative_feature + 1) + d_onehot = ms.mint.nn.functional.one_hot( + d, 2 * self.max_relative_feature + 1 + 1 + ) + E = self.linear(d_onehot.float()) + return E + + +class CA_ProteinFeatures(nn.Cell): + """ + Extract protein features. + + Args: + edge_features (int): Number of edge features. + node_features (int): Number of node features. + num_positional_embeddings (int, optional): Number of positional embeddings. Defaults to 16. + num_rbf (int, optional): Number of radial basis functions. Defaults to 16. + top_k (int, optional): Top k neighbors. Defaults to 30. + augment_eps (float, optional): Augmentation epsilon. Defaults to 0.0. + num_chain_embeddings (int, optional): Number of chain embeddings. Defaults to 16. + """ + def __init__( + self, + edge_features, + node_features, + num_positional_embeddings=16, + num_rbf=16, + top_k=30, + augment_eps=0.0, + ): + """Extract protein features""" + super().__init__() + self.edge_features = edge_features + self.node_features = node_features + self.top_k = top_k + self.augment_eps = augment_eps + self.num_rbf = num_rbf + self.num_positional_embeddings = num_positional_embeddings + + # Positional encoding + self.embeddings = PositionalEncodings(num_positional_embeddings) + # Normalization and embedding + node_in, edge_in = 3, num_positional_embeddings + num_rbf * 9 + 7 + self.node_embedding = nn.Linear(node_in, node_features, bias=False) # NOT USED + self.edge_embedding = nn.Linear(edge_in, edge_features, bias=False) + self.norm_nodes = nn.LayerNorm((node_features,)) + self.norm_edges = nn.LayerNorm((edge_features,)) + + def _quaternions(self, R): + """Convert a batch of 3D rotations [R] to quaternions [Q] + R [...,3,3] + Q [...,4] + """ + # Simple Wikipedia version + # en.wikipedia.org/wiki/Rotation_matrix#Quaternion + # For other options see math.stackexchange.com/questions/2074316/calculating-rotation-axis-from-rotation-matrix + diag = ops.diagonal(R, dim1=-2, dim2=-1) + Rxx, Ryy, Rzz = diag.unbind(-1) + magnitudes = 0.5 * ms.mint.sqrt( + ms.mint.abs( + 1 + + ms.mint.stack( + [Rxx - Ryy - Rzz, -Rxx + Ryy - Rzz, -Rxx - Ryy + Rzz], -1 + ) + ) + ) + def _R(i, j): + return R[:, :, :, i, j] + signs = ms.mint.sign( + ms.mint.stack( + [_R(2, 1) - _R(1, 2), _R(0, 2) - _R(2, 0), _R(1, 0) - _R(0, 1)], -1 + ) + ) + xyz = signs * magnitudes + # The relu enforces a non-negative trace + w = ms.mint.sqrt(ops.relu(1 + diag.sum(-1, keepdim=True))) / 2.0 + Q = ms.mint.cat((xyz, w), -1) + Q = ms.mint.nn.functional.normalize(Q, dim=-1) + return Q + + def _orientations_coarse(self, X, E_idx, eps=1e-6): + """ + Compute orientations from coarse CA-CA positions. + """ + dX = X[:, 1:, :] - X[:, :-1, :] + dX_norm = ms.mint.norm(dX, dim=-1) + dX_mask = (3.6 < dX_norm) & (dX_norm < 4.0) # exclude CA-CA jumps + dX = dX * dX_mask[:, :, None] + U = ms.mint.nn.functional.normalize(dX, dim=-1) + u_2 = U[:, :-2, :] + u_1 = U[:, 1:-1, :] + u_0 = U[:, 2:, :] + # Backbone normals + n_2 = ms.mint.nn.functional.normalize(ms.mint.cross(u_2, u_1), dim=-1) + n_1 = ms.mint.nn.functional.normalize(ms.mint.cross(u_1, u_0), dim=-1) + + # Bond angle calculation + cosA = -(u_1 * u_0).sum(-1) + cosA = ms.mint.clamp(cosA, -1 + eps, 1 - eps) + A = ms.mint.acos(cosA) + # Angle between normals + cosD = (n_2 * n_1).sum(-1) + cosD = ms.mint.clamp(cosD, -1 + eps, 1 - eps) + D = ms.mint.sign((u_2 * n_1).sum(-1)) * ms.mint.acos(cosD) + # Backbone features + AD_features = ms.mint.stack( + ( + ms.mint.cos(A), + ms.mint.sin(A) * ms.mint.cos(D), + ms.mint.sin(A) * ms.mint.sin(D), + ), + 2, + ) + AD_features = ops.pad(AD_features, (0, 0, 1, 2), "constant", 0) + + # Build relative orientations + o_1 = ms.mint.nn.functional.normalize(u_2 - u_1, dim=-1) + O = ms.mint.stack((o_1, n_2, ms.mint.cross(o_1, n_2)), 2) + O = O.view(list(O.shape[:2]) + [9]) + O = ops.pad(O, (0, 0, 1, 2), "constant", 0) + O_neighbors = gather_nodes(O, E_idx) + X_neighbors = gather_nodes(X, E_idx) + + # Re-view as rotation matrices + O = O.view(list(O.shape[:2]) + [3, 3]) + O_neighbors = O_neighbors.view(list(O_neighbors.shape[:3]) + [3, 3]) + + # Rotate into local reference frames + dX = X_neighbors - X.unsqueeze(-2) + dU = ms.mint.matmul(O.unsqueeze(2), dX.unsqueeze(-1)).squeeze(-1) + dU = ms.mint.nn.functional.normalize(dU, dim=-1) + R = ms.mint.matmul(O.unsqueeze(2).transpose(-1, -2), O_neighbors) + Q = self._quaternions(R) + + # Orientation features + O_features = ms.mint.cat((dU, Q), dim=-1) + return AD_features, O_features + + def _dist(self, X, mask, eps=1e-6): + """Pairwise euclidean distances""" + # Convolutional network on NCHW + mask_2D = ms.mint.unsqueeze(mask, 1) * ms.mint.unsqueeze(mask, 2) + dX = ms.mint.unsqueeze(X, 1) - ms.mint.unsqueeze(X, 2) + D = mask_2D * ms.mint.sqrt(ms.mint.sum(dX**2, 3) + eps) + + # Identify k nearest neighbors (including self) + D_max, _ = ms.mint.max(D, -1, keepdim=True) + D_adjust = D + (1.0 - mask_2D) * D_max + D_neighbors, E_idx = ms.mint.topk( + D_adjust, np.minimum(self.top_k, X.shape[1]).item(), dim=-1, largest=False + ) + mask_neighbors = gather_edges(mask_2D.unsqueeze(-1), E_idx) + return D_neighbors, E_idx, mask_neighbors + + def _rbf(self, D): + # Distance radial basis function + D_min, D_max, D_count = 2.0, 22.0, self.num_rbf + D_mu = ops.linspace(D_min, D_max, D_count) + D_mu = D_mu.view([1, 1, 1, -1]) + D_sigma = (D_max - D_min) / D_count + D_expand = ms.mint.unsqueeze(D, -1) + RBF = ms.mint.exp(-(((D_expand - D_mu) / D_sigma) ** 2)) + return RBF + + def _get_rbf(self, A, B, E_idx): + D_A_B = ms.mint.sqrt( + ms.mint.sum((A[:, :, None, :] - B[:, None, :, :]) ** 2, -1) + 1e-6 + ) # [B, L, L] + D_A_B_neighbors = gather_edges(D_A_B[:, :, :, None], E_idx)[ + :, :, :, 0 + ] # [B,L,K] + RBF_A_B = self._rbf(D_A_B_neighbors) + return RBF_A_B + + def construct(self, Ca, mask, residue_idx, chain_labels): + """Featurize coordinates as an attributed graph""" + if self.augment_eps > 0: + Ca = Ca + self.augment_eps * ms.mint.randn_like(Ca) + + D_neighbors, E_idx, _ = self._dist(Ca, mask) + + Ca_0 = ms.mint.zeros(Ca.shape) + Ca_2 = ms.mint.zeros(Ca.shape) + Ca_0[:, 1:, :] = Ca[:, :-1, :] + Ca_1 = Ca + Ca_2[:, :-1, :] = Ca[:, 1:, :] + + _, O_features = self._orientations_coarse(Ca, E_idx) + + RBF_all = [] + RBF_all.append(self._rbf(D_neighbors)) # Ca_1-Ca_1 + RBF_all.append(self._get_rbf(Ca_0, Ca_0, E_idx)) + RBF_all.append(self._get_rbf(Ca_2, Ca_2, E_idx)) + + RBF_all.append(self._get_rbf(Ca_0, Ca_1, E_idx)) + RBF_all.append(self._get_rbf(Ca_0, Ca_2, E_idx)) + + RBF_all.append(self._get_rbf(Ca_1, Ca_0, E_idx)) + RBF_all.append(self._get_rbf(Ca_1, Ca_2, E_idx)) + + RBF_all.append(self._get_rbf(Ca_2, Ca_0, E_idx)) + RBF_all.append(self._get_rbf(Ca_2, Ca_1, E_idx)) + + RBF_all = ms.mint.cat(tuple(RBF_all), dim=-1) + + offset = residue_idx[:, :, None] - residue_idx[:, None, :] + offset = gather_edges(offset[:, :, :, None], E_idx)[:, :, :, 0] # [B, L, K] + + d_chains = ((chain_labels[:, :, None] - chain_labels[:, None, :]) == 0).long() + E_chains = gather_edges(d_chains[:, :, :, None], E_idx)[:, :, :, 0] + E_positional = self.embeddings(offset.long(), E_chains) + E = ms.mint.cat((E_positional, RBF_all, O_features), -1) + + E = self.edge_embedding(E) + E = self.norm_edges(E) + + return E, E_idx + + +class ProteinFeatures(nn.Cell): + """ + Extract protein features. + + Args: + edge_features (int): Number of edge features. + node_features (int): Number of node features. + num_positional_embeddings (int, optional): Number of positional embeddings. Defaults to 16. + num_rbf (int, optional): Number of radial basis functions. Defaults to 16. + top_k (int, optional): Top k neighbors. Defaults to 30. + augment_eps (float, optional): Augmentation epsilon. Defaults to 0.0. + num_chain_embeddings (int, optional): Number of chain embeddings. Defaults to 16. + """ + def __init__( + self, + edge_features, + node_features, + num_positional_embeddings=16, + num_rbf=16, + top_k=30, + augment_eps=0.0, + ): + """Extract protein features""" + super().__init__() + self.edge_features = edge_features + self.node_features = node_features + self.top_k = top_k + self.augment_eps = augment_eps + self.num_rbf = num_rbf + self.num_positional_embeddings = num_positional_embeddings + + self.embeddings = PositionalEncodings(num_positional_embeddings) + _, edge_in = 6, num_positional_embeddings + num_rbf * 25 + self.edge_embedding = nn.Linear(edge_in, edge_features, bias=False) + self.norm_edges = nn.LayerNorm((edge_features,)) + + def _dist(self, X, mask, eps=1e-6): + mask_2D = ms.mint.unsqueeze(mask, 1) * ms.mint.unsqueeze(mask, 2) + dX = ms.mint.unsqueeze(X, 1) - ms.mint.unsqueeze(X, 2) + D = mask_2D * ms.mint.sqrt(ms.mint.sum(dX**2, 3) + eps) + D_max, _ = ms.mint.max(D, -1, keepdim=True) + D_adjust = D + (1.0 - mask_2D) * D_max + D_neighbors, E_idx = ms.mint.topk( + D_adjust, np.minimum(self.top_k, X.shape[1]).item(), dim=-1, largest=False + ) + return D_neighbors, E_idx + + def _rbf(self, D): + D_min, D_max, D_count = 2.0, 22.0, self.num_rbf + D_mu = ops.linspace(D_min, D_max, D_count) + D_mu = D_mu.view([1, 1, 1, -1]) + D_sigma = (D_max - D_min) / D_count + D_expand = ms.mint.unsqueeze(D, -1) + RBF = ms.mint.exp(-(((D_expand - D_mu) / D_sigma) ** 2)) + return RBF + + def _get_rbf(self, A, B, E_idx): + D_A_B = ms.mint.sqrt( + ms.mint.sum((A[:, :, None, :] - B[:, None, :, :]) ** 2, -1) + 1e-6 + ) # [B, L, L] + D_A_B_neighbors = gather_edges(D_A_B[:, :, :, None], E_idx)[ + :, :, :, 0 + ] # [B,L,K] + RBF_A_B = self._rbf(D_A_B_neighbors) + return RBF_A_B + + def construct(self, X, mask, residue_idx, chain_labels): + """ + Extract protein features. + """ + if self.augment_eps > 0: + X = X + self.augment_eps * ms.mint.randn_like(X) + + b = X[:, :, 1, :] - X[:, :, 0, :] + c = X[:, :, 2, :] - X[:, :, 1, :] + a = ms.mint.cross(b, c, dim=-1) + Cb = -0.58273431 * a + 0.56802827 * b - 0.54067466 * c + X[:, :, 1, :] + Ca = X[:, :, 1, :] + N = X[:, :, 0, :] + C = X[:, :, 2, :] + O = X[:, :, 3, :] + + D_neighbors, E_idx = self._dist(Ca, mask) + + RBF_all = [] + RBF_all.append(self._rbf(D_neighbors)) # Ca-Ca + RBF_all.append(self._get_rbf(N, N, E_idx)) # N-N + RBF_all.append(self._get_rbf(C, C, E_idx)) # C-C + RBF_all.append(self._get_rbf(O, O, E_idx)) # O-O + RBF_all.append(self._get_rbf(Cb, Cb, E_idx)) # Cb-Cb + RBF_all.append(self._get_rbf(Ca, N, E_idx)) # Ca-N + RBF_all.append(self._get_rbf(Ca, C, E_idx)) # Ca-C + RBF_all.append(self._get_rbf(Ca, O, E_idx)) # Ca-O + RBF_all.append(self._get_rbf(Ca, Cb, E_idx)) # Ca-Cb + RBF_all.append(self._get_rbf(N, C, E_idx)) # N-C + RBF_all.append(self._get_rbf(N, O, E_idx)) # N-O + RBF_all.append(self._get_rbf(N, Cb, E_idx)) # N-Cb + RBF_all.append(self._get_rbf(Cb, C, E_idx)) # Cb-C + RBF_all.append(self._get_rbf(Cb, O, E_idx)) # Cb-O + RBF_all.append(self._get_rbf(O, C, E_idx)) # O-C + RBF_all.append(self._get_rbf(N, Ca, E_idx)) # N-Ca + RBF_all.append(self._get_rbf(C, Ca, E_idx)) # C-Ca + RBF_all.append(self._get_rbf(O, Ca, E_idx)) # O-Ca + RBF_all.append(self._get_rbf(Cb, Ca, E_idx)) # Cb-Ca + RBF_all.append(self._get_rbf(C, N, E_idx)) # C-N + RBF_all.append(self._get_rbf(O, N, E_idx)) # O-N + RBF_all.append(self._get_rbf(Cb, N, E_idx)) # Cb-N + RBF_all.append(self._get_rbf(C, Cb, E_idx)) # C-Cb + RBF_all.append(self._get_rbf(O, Cb, E_idx)) # O-Cb + RBF_all.append(self._get_rbf(C, O, E_idx)) # C-O + RBF_all = ms.mint.cat(tuple(RBF_all), dim=-1) + + offset = residue_idx[:, :, None] - residue_idx[:, None, :] + offset = gather_edges(offset[:, :, :, None], E_idx)[:, :, :, 0] # [B, L, K] + + d_chains = ( + (chain_labels[:, :, None] - chain_labels[:, None, :]) == 0 + ).long() # find self vs non-self interaction + E_chains = gather_edges(d_chains[:, :, :, None], E_idx)[:, :, :, 0] + E_positional = self.embeddings(offset.long(), E_chains) + E = ms.mint.cat((E_positional, RBF_all), -1) + E = self.edge_embedding(E) + E = self.norm_edges(E) + return E, E_idx + + +class ProteinMPNN(nn.Cell): + """ + ProteinMPNN model. + + Args: + num_letters (int): Number of letters. + node_features (int): Number of node features. + edge_features (int): Number of edge features. + hidden_dim (int): Hidden dimension. + num_encoder_layers (int, optional): Number of encoder layers. Defaults to 3. + num_decoder_layers (int, optional): Number of decoder layers. Defaults to 3. + vocab (int, optional): Vocabulary size. Defaults to 21. + k_neighbors (int, optional): Top k neighbors. Defaults to 64. + augment_eps (float, optional): Augmentation epsilon. Defaults to 0.05. + dropout (float, optional): Dropout rate. Defaults to 0.1. + ca_only (bool, optional): Whether to use only CA atoms. Defaults to False. + """ + def __init__( + self, + num_letters, + node_features, + edge_features, + hidden_dim, + num_encoder_layers=3, + num_decoder_layers=3, + vocab=21, + k_neighbors=64, + augment_eps=0.05, + dropout=0.1, + ca_only=False, + ): + super().__init__() + + # Hyperparameters + self.node_features = node_features + self.edge_features = edge_features + self.hidden_dim = hidden_dim + + # Featurization layers + if ca_only: + self.features = CA_ProteinFeatures( + node_features, edge_features, top_k=k_neighbors, augment_eps=augment_eps + ) + self.W_v = nn.Linear(node_features, hidden_dim, bias=True) + else: + self.features = ProteinFeatures( + node_features, edge_features, top_k=k_neighbors, augment_eps=augment_eps + ) + + self.W_e = nn.Linear(edge_features, hidden_dim, bias=True) + self.W_s = nn.Embedding(vocab, hidden_dim) + + # Encoder layers + self.encoder_layers = nn.CellList( + [ + EncLayer(hidden_dim, hidden_dim * 2, dropout=dropout) + for _ in range(num_encoder_layers) + ] + ) + + # Decoder layers + self.decoder_layers = nn.CellList( + [ + DecLayer(hidden_dim, hidden_dim * 3, dropout=dropout) + for _ in range(num_decoder_layers) + ] + ) + self.W_out = nn.Linear(hidden_dim, num_letters, bias=True) + + def construct( + self, + X, + S, + mask, + chain_M, + residue_idx, + chain_encoding_all, + randn, + use_input_decoding_order=False, + decoding_order=None, + ): + """Graph-conditioned sequence model""" + # Prepare node and edge embeddings + E, E_idx = self.features(X, mask, residue_idx, chain_encoding_all) + h_V = ms.mint.zeros((E.shape[0], E.shape[1], E.shape[-1])) + h_E = self.W_e(E) + + # Encoder is unmasked self-attention + mask_attend = gather_nodes(mask.unsqueeze(-1), E_idx).squeeze(-1) + mask_attend = mask.unsqueeze(-1) * mask_attend + for layer in self.encoder_layers: + h_V, h_E = layer(h_V, h_E, E_idx, mask, mask_attend) + + # Concatenate sequence embeddings for autoregressive decoder + h_S = self.W_s(S) + h_ES = cat_neighbors_nodes(h_S, h_E, E_idx) + + # Build encoder embeddings + h_EX_encoder = cat_neighbors_nodes(ms.mint.zeros_like(h_S), h_E, E_idx) + h_EXV_encoder = cat_neighbors_nodes(h_V, h_EX_encoder, E_idx) + + chain_M = chain_M * mask # update chain_M to include missing regions + if not use_input_decoding_order: + decoding_order = ms.mint.argsort( + (chain_M + 0.0001) * (ms.mint.abs(randn)) + ) # [numbers will be smaller for places where chain_M = 0.0 and higher for places where chain_M = 1.0] + mask_size = E_idx.shape[1] + permutation_matrix_reverse = ms.mint.nn.functional.one_hot( + decoding_order, num_classes=mask_size + ).float() + order_mask_backward = ms.mint.einsum( + "ij, biq, bjp->bqp", + (1 - ms.mint.triu(ms.mint.ones((mask_size, mask_size)))), + permutation_matrix_reverse, + permutation_matrix_reverse, + ) + mask_attend = ms.mint.gather(order_mask_backward, 2, E_idx).unsqueeze(-1) + mask_1D = mask.view([mask.shape[0], mask.shape[1], 1, 1]) + mask_bw = mask_1D * mask_attend + mask_fw = mask_1D * (1.0 - mask_attend) + + h_EXV_encoder_fw = mask_fw * h_EXV_encoder + for layer in self.decoder_layers: + # Masked positions attend to encoder information, unmasked see. + h_ESV = cat_neighbors_nodes(h_V, h_ES, E_idx) + h_ESV = mask_bw * h_ESV + h_EXV_encoder_fw + h_V = layer(h_V, h_ESV, mask) + + logits = self.W_out(h_V) + log_probs = ms.mint.nn.functional.log_softmax(logits, dim=-1) + return log_probs + + def sample( + self, + X, + randn, + S_true, + chain_mask, + chain_encoding_all, + residue_idx, + mask=None, + temperature=1.0, + omit_AAs_np=None, + bias_AAs_np=None, + chain_M_pos=None, + omit_AA_mask=None, + pssm_coef=None, + pssm_bias=None, + pssm_multi=None, + pssm_log_odds_flag=None, + pssm_log_odds_mask=None, + pssm_bias_flag=None, + bias_by_res=None, + ): + """ + Sample sequences from the model + """ + # Prepare node and edge embeddings + E, E_idx = self.features(X, mask, residue_idx, chain_encoding_all) + h_V = ms.mint.zeros((E.shape[0], E.shape[1], E.shape[-1])) + h_E = self.W_e(E) + + # Encoder is unmasked self-attention + mask_attend = gather_nodes(mask.unsqueeze(-1), E_idx).squeeze(-1) + mask_attend = mask.unsqueeze(-1) * mask_attend + for layer in self.encoder_layers: + h_V, h_E = layer(h_V, h_E, E_idx, mask, mask_attend) + + # Decoder uses masked self-attention + chain_mask = ( + chain_mask * chain_M_pos * mask + ) # update chain_M to include missing regions + decoding_order = ms.mint.argsort( + (chain_mask + 0.0001) * (ms.mint.abs(randn)) + ) # [numbers will be smaller for places where chain_M = 0.0 and higher for places where chain_M = 1.0] + mask_size = E_idx.shape[1] + permutation_matrix_reverse = ms.mint.nn.functional.one_hot( + decoding_order, num_classes=mask_size + ).float() + order_mask_backward = ms.mint.einsum( + "ij, biq, bjp->bqp", + (1 - ms.mint.triu(ms.mint.ones((mask_size, mask_size)))), + permutation_matrix_reverse, + permutation_matrix_reverse, + ) + mask_attend = ms.mint.gather(order_mask_backward, 2, E_idx).unsqueeze(-1) + mask_1D = mask.view([mask.shape[0], mask.shape[1], 1, 1]) + mask_bw = mask_1D * mask_attend + mask_fw = mask_1D * (1.0 - mask_attend) + + N_batch, N_nodes = X.shape[0], X.shape[1] + all_probs = ms.mint.zeros((N_batch, N_nodes, 21), dtype=ms.float32) + h_S = ms.mint.zeros_like(h_V) + S = ms.mint.zeros((N_batch, N_nodes), dtype=ms.int64) + h_V_stack = [h_V] + [ + ms.mint.zeros_like(h_V) for _ in range(len(self.decoder_layers)) + ] + constant = ms.tensor(omit_AAs_np) + constant_bias = ms.tensor(bias_AAs_np) + # chain_mask_combined = chain_mask*chain_M_pos + omit_AA_mask_flag = omit_AA_mask is not None + + h_EX_encoder = cat_neighbors_nodes(ms.mint.zeros_like(h_S), h_E, E_idx) + h_EXV_encoder = cat_neighbors_nodes(h_V, h_EX_encoder, E_idx) + h_EXV_encoder_fw = mask_fw * h_EXV_encoder + for t_ in range(N_nodes): + t = decoding_order[:, t_] # [B] + chain_mask_gathered = ms.mint.gather(chain_mask, 1, t[:, None]) # [B] + mask_gathered = ms.mint.gather(mask, 1, t[:, None]) # [B] + bias_by_res_gathered = ms.mint.gather( + bias_by_res, 1, t[:, None, None].repeat(1, 1, 21) + )[:, 0, :] # [B, 21] + if (mask_gathered == 0).all(): # for padded or missing regions only + S_t = ms.mint.gather(S_true, 1, t[:, None]) + else: + # Hidden layers + E_idx_t = ms.mint.gather( + E_idx, 1, t[:, None, None].repeat(1, 1, E_idx.shape[-1]) + ) + h_E_t = ms.mint.gather( + h_E, + 1, + t[:, None, None, None].repeat(1, 1, h_E.shape[-2], h_E.shape[-1]), + ) + h_ES_t = cat_neighbors_nodes(h_S, h_E_t, E_idx_t) + h_EXV_encoder_t = ms.mint.gather( + h_EXV_encoder_fw, + 1, + t[:, None, None, None].repeat( + 1, 1, h_EXV_encoder_fw.shape[-2], h_EXV_encoder_fw.shape[-1] + ), + ) + mask_t = ms.mint.gather(mask, 1, t[:, None]) + for l, layer in enumerate(self.decoder_layers): + # Updated relational features for future states + h_ESV_decoder_t = cat_neighbors_nodes(h_V_stack[l], h_ES_t, E_idx_t) + h_V_t = ms.mint.gather( + h_V_stack[l], + 1, + t[:, None, None].repeat(1, 1, h_V_stack[l].shape[-1]), + ) + h_ESV_t = ( + ms.mint.gather( + mask_bw, + 1, + t[:, None, None, None].repeat( + 1, 1, mask_bw.shape[-2], mask_bw.shape[-1] + ), + ) + * h_ESV_decoder_t + + h_EXV_encoder_t + ) + h_V_stack[l + 1].scatter_( + 1, + t[:, None, None].repeat(1, 1, h_V.shape[-1]), + layer(h_V_t, h_ESV_t, mask_V=mask_t), + ) + # Sampling step + h_V_t = ms.mint.gather( + h_V_stack[-1], + 1, + t[:, None, None].repeat(1, 1, h_V_stack[-1].shape[-1]), + )[:, 0] + logits = self.W_out(h_V_t) / temperature + probs = ms.mint.nn.functional.softmax( + logits + - constant[None, :] * 1e8 + + constant_bias[None, :] / temperature + + bias_by_res_gathered / temperature, + dim=-1, + ) + if pssm_bias_flag: + pssm_coef_gathered = ms.mint.gather(pssm_coef, 1, t[:, None])[:, 0] + pssm_bias_gathered = ms.mint.gather( + pssm_bias, 1, t[:, None, None].repeat(1, 1, pssm_bias.shape[-1]) + )[:, 0] + probs = ( + 1 - pssm_multi * pssm_coef_gathered[:, None] + ) * probs + pssm_multi * pssm_coef_gathered[ + :, None + ] * pssm_bias_gathered + if pssm_log_odds_flag: + pssm_log_odds_mask_gathered = ms.mint.gather( + pssm_log_odds_mask, + 1, + t[:, None, None].repeat(1, 1, pssm_log_odds_mask.shape[-1]), + )[:, 0] # [B, 21] + probs_masked = probs * pssm_log_odds_mask_gathered + probs_masked += probs * 0.001 + probs = probs_masked / ms.mint.sum( + probs_masked, dim=-1, keepdim=True + ) # [B, 21] + if omit_AA_mask_flag: + omit_AA_mask_gathered = ms.mint.gather( + omit_AA_mask, + 1, + t[:, None, None].repeat(1, 1, omit_AA_mask.shape[-1]), + )[:, 0] # [B, 21] + probs_masked = probs * (1.0 - omit_AA_mask_gathered) + probs = probs_masked / ms.mint.sum( + probs_masked, dim=-1, keepdim=True + ) # [B, 21] + S_t = ms.mint.multinomial(probs, 1) + all_probs.scatter_( + 1, + t[:, None, None].repeat(1, 1, 21), + ( + chain_mask_gathered[ + :, + :, + None, + ] + * probs[:, None, :] + ).float(), + ) + S_true_gathered = ms.mint.gather(S_true, 1, t[:, None]) + S_t = ( + S_t * chain_mask_gathered + + S_true_gathered * (1.0 - chain_mask_gathered) + ).long() + temp1 = self.W_s(S_t) + h_S.scatter_(1, t[:, None, None].repeat(1, 1, temp1.shape[-1]), temp1) + S.scatter_(1, t[:, None], S_t) + output_dict = {"S": S, "probs": all_probs, "decoding_order": decoding_order} + return output_dict + + def tied_sample( + self, + X, + randn, + S_true, + chain_mask, + chain_encoding_all, + residue_idx, + mask=None, + temperature=1.0, + omit_AAs_np=None, + bias_AAs_np=None, + chain_M_pos=None, + omit_AA_mask=None, + pssm_coef=None, + pssm_bias=None, + pssm_multi=None, + pssm_log_odds_flag=None, + pssm_log_odds_mask=None, + pssm_bias_flag=None, + tied_pos=None, + tied_beta=None, + bias_by_res=None, + ): + """ + Sample sequences from the model using tied positions + """ + # Prepare node and edge embeddings + E, E_idx = self.features(X, mask, residue_idx, chain_encoding_all) + h_V = ms.mint.zeros((E.shape[0], E.shape[1], E.shape[-1])) + h_E = self.W_e(E) + # Encoder is unmasked self-attention + mask_attend = gather_nodes(mask.unsqueeze(-1), E_idx).squeeze(-1) + mask_attend = mask.unsqueeze(-1) * mask_attend + for layer in self.encoder_layers: + h_V, h_E = layer(h_V, h_E, E_idx, mask, mask_attend) + + # Decoder uses masked self-attention + chain_mask = ( + chain_mask * chain_M_pos * mask + ) # update chain_M to include missing regions + decoding_order = ms.mint.argsort( + (chain_mask + 0.0001) * (ms.mint.abs(randn)) + ) # [numbers will be smaller for places where chain_M = 0.0 and higher for places where chain_M = 1.0] + + new_decoding_order = [] + for t_dec in list(decoding_order[0,].data.numpy()): + if t_dec not in list(itertools.chain(*new_decoding_order)): + list_a = [item for item in tied_pos if t_dec in item] + if list_a: + new_decoding_order.append(list_a[0]) + else: + new_decoding_order.append([t_dec]) + decoding_order = ms.tensor(list(itertools.chain(*new_decoding_order)))[ + None, + ].repeat(X.shape[0], 1) + + mask_size = E_idx.shape[1] + permutation_matrix_reverse = ms.mint.nn.functional.one_hot( + decoding_order, num_classes=mask_size + ).float() + order_mask_backward = ms.mint.einsum( + "ij, biq, bjp->bqp", + (1 - ms.mint.triu(ms.mint.ones((mask_size, mask_size)))), + permutation_matrix_reverse, + permutation_matrix_reverse, + ) + mask_attend = ms.mint.gather(order_mask_backward, 2, E_idx).unsqueeze(-1) + mask_1D = mask.view([mask.shape[0], mask.shape[1], 1, 1]) + mask_bw = mask_1D * mask_attend + mask_fw = mask_1D * (1.0 - mask_attend) + + N_batch, N_nodes = X.shape[0], X.shape[1] + all_probs = ms.mint.zeros((N_batch, N_nodes, 21), dtype=ms.float32) + h_S = ms.mint.zeros_like(h_V) + S = ms.mint.zeros((N_batch, N_nodes), dtype=ms.int64) + h_V_stack = [h_V] + [ + ms.mint.zeros_like(h_V) for _ in range(len(self.decoder_layers)) + ] + constant = ms.tensor(omit_AAs_np) + constant_bias = ms.tensor(bias_AAs_np) + omit_AA_mask_flag = omit_AA_mask is not None + + h_EX_encoder = cat_neighbors_nodes(ms.mint.zeros_like(h_S), h_E, E_idx) + h_EXV_encoder = cat_neighbors_nodes(h_V, h_EX_encoder, E_idx) + h_EXV_encoder_fw = mask_fw * h_EXV_encoder + for t_list in new_decoding_order: + logits = 0.0 + logit_list = [] + done_flag = False + for t in t_list: + if not isinstance(t, int): + t = t.item() + if (mask[:, t] == 0).all(): + S_t = S_true[:, t] + for t in t_list: + if not isinstance(t, int): + t = t.item() + h_S[:, t, :] = self.W_s(S_t) + S[:, t] = S_t + done_flag = True + break + + E_idx_t = E_idx[:, t : t + 1, :] + h_E_t = h_E[:, t : t + 1, :, :] + h_ES_t = cat_neighbors_nodes(h_S, h_E_t, E_idx_t) + h_EXV_encoder_t = h_EXV_encoder_fw[:, t : t + 1, :, :] + mask_t = mask[:, t : t + 1] + for l, layer in enumerate(self.decoder_layers): + h_ESV_decoder_t = cat_neighbors_nodes( + h_V_stack[l], h_ES_t, E_idx_t + ) + h_V_t = h_V_stack[l][:, t : t + 1, :] + h_ESV_t = ( + mask_bw[:, t : t + 1, :, :] * h_ESV_decoder_t + + h_EXV_encoder_t + ) + h_V_stack[l + 1][:, t, :] = layer( + h_V_t, h_ESV_t, mask_V=mask_t + ).squeeze(1) + h_V_t = h_V_stack[-1][:, t, :] + logit_list.append((self.W_out(h_V_t) / temperature) / len(t_list)) + logits += ( + tied_beta[t] * (self.W_out(h_V_t) / temperature) / len(t_list) + ) + if done_flag: + pass + else: + bias_by_res_gathered = bias_by_res[:, t, :] # [B, 21] + probs = ms.mint.nn.functional.softmax( + logits + - constant[None, :] * 1e8 + + constant_bias[None, :] / temperature + + bias_by_res_gathered / temperature, + dim=-1, + ) + if pssm_bias_flag: + pssm_coef_gathered = pssm_coef[:, t] + pssm_bias_gathered = pssm_bias[:, t] + probs = ( + 1 - pssm_multi * pssm_coef_gathered[:, None] + ) * probs + pssm_multi * pssm_coef_gathered[ + :, None + ] * pssm_bias_gathered + if pssm_log_odds_flag: + pssm_log_odds_mask_gathered = pssm_log_odds_mask[:, t] + probs_masked = probs * pssm_log_odds_mask_gathered + probs_masked += probs * 0.001 + probs = probs_masked / ms.mint.sum( + probs_masked, dim=-1, keepdim=True + ) # [B, 21] + if omit_AA_mask_flag: + omit_AA_mask_gathered = omit_AA_mask[:, t] + probs_masked = probs * (1.0 - omit_AA_mask_gathered) + probs = probs_masked / ms.mint.sum( + probs_masked, dim=-1, keepdim=True + ) # [B, 21] + S_t_repeat = ms.mint.multinomial(probs, 1).squeeze(-1) + S_t_repeat = ( + chain_mask[:, t] * S_t_repeat + + (1 - chain_mask[:, t]) * S_true[:, t] + ).long() # hard pick fixed positions + for t in t_list: + if not isinstance(t, int): + t = t.item() + h_S[:, t, :] = self.W_s(S_t_repeat) + S[:, t] = S_t_repeat + all_probs[:, t, :] = probs.float() + output_dict = {"S": S, "probs": all_probs, "decoding_order": decoding_order} + return output_dict + + def conditional_probs( + self, + X, + S, + mask, + chain_M, + residue_idx, + chain_encoding_all, + randn, + backbone_only=False, + ): + """Graph-conditioned sequence model""" + # Prepare node and edge embeddings + E, E_idx = self.features(X, mask, residue_idx, chain_encoding_all) + h_V_enc = ms.mint.zeros((E.shape[0], E.shape[1], E.shape[-1])) + h_E = self.W_e(E) + + # Encoder is unmasked self-attention + mask_attend = gather_nodes(mask.unsqueeze(-1), E_idx).squeeze(-1) + mask_attend = mask.unsqueeze(-1) * mask_attend + for layer in self.encoder_layers: + h_V_enc, h_E = layer(h_V_enc, h_E, E_idx, mask, mask_attend) + + # Concatenate sequence embeddings for autoregressive decoder + h_S = self.W_s(S) + h_ES = cat_neighbors_nodes(h_S, h_E, E_idx) + + # Build encoder embeddings + h_EX_encoder = cat_neighbors_nodes(ms.mint.zeros_like(h_S), h_E, E_idx) + h_EXV_encoder = cat_neighbors_nodes(h_V_enc, h_EX_encoder, E_idx) + + chain_M = chain_M * mask # update chain_M to include missing regions + + chain_M_np = chain_M.numpy() + idx_to_loop = np.argwhere(chain_M_np[0, :] == 1)[:, 0] + log_conditional_probs = ms.mint.zeros( + [X.shape[0], chain_M.shape[1], 21] + ).float() + + for idx in idx_to_loop: + h_V = ms.mint.clone(h_V_enc) + order_mask = ms.mint.zeros(chain_M.shape[1]).float() + if backbone_only: + order_mask = ms.mint.ones(chain_M.shape[1]).float() + order_mask[idx] = 0.0 + else: + order_mask = ms.mint.zeros(chain_M.shape[1]).float() + order_mask[idx] = 1.0 + decoding_order = ms.mint.argsort( + (order_mask[None,] + 0.0001) * (ms.mint.abs(randn)) + ) # [numbers will be smaller for places where chain_M = 0.0 and higher for places where chain_M = 1.0] + mask_size = E_idx.shape[1] + permutation_matrix_reverse = ms.mint.nn.functional.one_hot( + decoding_order, num_classes=mask_size + ).float() + order_mask_backward = ms.mint.einsum( + "ij, biq, bjp->bqp", + (1 - ms.mint.triu(ms.mint.ones(mask_size, mask_size))), + permutation_matrix_reverse, + permutation_matrix_reverse, + ) + mask_attend = ms.mint.gather(order_mask_backward, 2, E_idx).unsqueeze(-1) + mask_1D = mask.view([mask.shape[0], mask.shape[1], 1, 1]) + mask_bw = mask_1D * mask_attend + mask_fw = mask_1D * (1.0 - mask_attend) + + h_EXV_encoder_fw = mask_fw * h_EXV_encoder + for layer in self.decoder_layers: + # Masked positions attend to encoder information, unmasked see. + h_ESV = cat_neighbors_nodes(h_V, h_ES, E_idx) + h_ESV = mask_bw * h_ESV + h_EXV_encoder_fw + h_V = layer(h_V, h_ESV, mask) + + logits = self.W_out(h_V) + log_probs = ops.log_softmax(logits, dim=-1) + log_conditional_probs[:, idx, :] = log_probs[:, idx, :] + return log_conditional_probs + + def unconditional_probs(self, X, mask, residue_idx, chain_encoding_all): + """Graph-conditioned sequence model""" + # Prepare node and edge embeddings + E, E_idx = self.features(X, mask, residue_idx, chain_encoding_all) + h_V = ms.mint.zeros((E.shape[0], E.shape[1], E.shape[-1])) + h_E = self.W_e(E) + + # Encoder is unmasked self-attention + mask_attend = gather_nodes(mask.unsqueeze(-1), E_idx).squeeze(-1) + mask_attend = mask.unsqueeze(-1) * mask_attend + for layer in self.encoder_layers: + h_V, h_E = layer(h_V, h_E, E_idx, mask, mask_attend) + + # Build encoder embeddings + h_EX_encoder = cat_neighbors_nodes(ms.mint.zeros_like(h_V), h_E, E_idx) + h_EXV_encoder = cat_neighbors_nodes(h_V, h_EX_encoder, E_idx) + + order_mask_backward = ms.mint.zeros([X.shape[0], X.shape[1], X.shape[1]]) + mask_attend = ms.mint.gather(order_mask_backward, 2, E_idx).unsqueeze(-1) + mask_1D = mask.view([mask.shape[0], mask.shape[1], 1, 1]) + mask_fw = mask_1D * (1.0 - mask_attend) + + h_EXV_encoder_fw = mask_fw * h_EXV_encoder + for layer in self.decoder_layers: + h_V = layer(h_V, h_EXV_encoder_fw, mask) + + logits = self.W_out(h_V) + log_probs = ms.mint.nn.functional.log_softmax(logits, dim=-1) + return log_probs diff --git a/MindSPONGE/applications/proteinmpnn/proteinmpnn/sample_features.py b/MindSPONGE/applications/proteinmpnn/proteinmpnn/sample_features.py new file mode 100644 index 000000000..8b9c5cd89 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/proteinmpnn/sample_features.py @@ -0,0 +1,134 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Sample features for ProteinMPNN +""" + +import os + +import numpy as np + +from .util_protein_mpnn import Pose, aa_1_3 + + +class SampleFeatures: + """ + This is a struct which keeps all the features related to a single sample together. + + Args: + pose (Pose): Pose object. + tag (str): Tag. + """ + + def __init__( + self, + pose: Pose, + tag: str, + ) -> None: + self.pose = pose + self.tag = os.path.basename(tag).split(".")[0] + + def loop_string2fixed_res( + self, + loop_string: str, + ) -> None: + """ + Given a loop string, create a dict of residues which should be designed by ProteinMPNN + + The dict is keyed by chain and the values are lists of residue indices. The lists are: + - 1-indexed + - Indexed relative to the start of the chain + """ + + # First do some sanity checks + + if loop_string == "": + raise Exception("Received empty loop string. Skipping example") + + desloops = [l.upper() for l in loop_string.split(",")] + + fixed_res = {} + + nchains = self.pose.chain.size + + if nchains <= 1: + raise Exception("Too few chains detected. Skipping") + + # Now, we will make a fixed res dictionary for each chain that indicates which residues in each + # chain should NOT be designed by ProteinMPNN + + # Determine the length of the H chain + lenH = np.where(self.pose.chain == "H")[0].size + lenL = np.where(self.pose.chain == "L")[0].size + + # Now we will parse the H chain loops (if any) + loopH = [] + for loop in desloops: + if "H" in loop: + loopH += self.pose.cdr_dict[loop] + + # Then we will parse the L chain loops (if any) + loopL = [] + for loop in desloops: + if "L" in loop: + loopL += self.pose.cdr_dict[loop] + + # We must now invert these "designable" residue lists into "fixed" residue lists + idxH = list(range(1, lenH + 1)) + for res in loopH: + idxH.remove(res) + + print(f"loopH: {loopH}") + print(f"loopL: {loopL}") + + idxL = list(range(lenH + 1, lenH + lenL + 1)) + for res in loopL: + idxL.remove(res) + + if "H" in self.pose.chain: + fixed_res["H"] = idxH + + if "L" in self.pose.chain: + fixed_res["L"] = [ + i - lenH for i in idxL + ] # We must subtract lenH to make the L chain 1-indexed + + if "T" in self.pose.chain: + lenT = np.where(self.pose.chain == "T")[0].size + + idxT = list(range(1, lenT + 1)) + + fixed_res["T"] = idxT + + # Final assignment + self.fixed_res = fixed_res + self.chains = np.unique(self.pose.chain).tolist() + + def thread_mpnn_seq(self, binder_seq: str) -> None: + """ + Thread the binder sequence onto the pose being designed + """ + + for resi, mut_to in enumerate(binder_seq): + name3 = aa_1_3[mut_to] + + self.pose.mutate_residue( + chain=self.pose.chain[resi], + residx=resi, + newres=name3, + ) diff --git a/MindSPONGE/applications/proteinmpnn/proteinmpnn/struct_manager.py b/MindSPONGE/applications/proteinmpnn/proteinmpnn/struct_manager.py new file mode 100644 index 000000000..dd6cefcfb --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/proteinmpnn/struct_manager.py @@ -0,0 +1,125 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +StructManager for ProteinMPNN +""" + +import glob +import os + +from .util_protein_mpnn import Pose + + +class StructManager: + """ + This class handles all of the input and output for the ProteinMPNN model. It deals with pdbs, + checkpointing, and writing of outputs. + + Args: + args (argparse.Namespace): Arguments. + """ + + def __init__(self, args) -> None: + self.args = args + + self.pdb = False + if not args.pdbdir == "": + self.pdb = True + + self.pdbdir = args.pdbdir + self.outpdbdir = args.outpdbdir + + self.struct_iterator = glob.glob(os.path.join(args.pdbdir, "*.pdb")) + + # Parse the runlist and determine which structures to process + if args.runlist != "": + with open(args.runlist, "r", encoding="utf-8") as f: + self.runlist = {line.strip() for line in f} + + # Filter the struct iterator to only include those in the runlist + self.struct_iterator = [ + struct + for struct in self.struct_iterator + if os.path.basename(struct).split(".")[0] in self.runlist + ] + + print( + f"After filtering by runlist, {len(self.struct_iterator)} structures remain" + ) + + # Setup checkpointing + self.chkfn = args.checkpoint_name + self.finished_structs = set() + + if os.path.isfile(self.chkfn): + with open(self.chkfn, "r", encoding="utf-8") as f: + for line in f: + self.finished_structs.add(line.strip()) + + def record_checkpoint(self, tag: str) -> None: + """ + Record the fact that this tag has been processed. + Write this tag to the list of finished structs + """ + with open(self.chkfn, "a", encoding="utf-8") as f: + f.write(f"{tag}\n") + + def iterate(self) -> str: + """ + Iterate over the silent file or pdb directory and run the model on each structure + """ + + # Iterate over the structs and for each, check that the struct has not already been processed + for struct in self.struct_iterator: + tag = os.path.basename(struct).split(".")[0] + if tag in self.finished_structs: + print(f"{tag} has already been processed. Skipping") + continue + + yield struct + + def dump_pose( + self, + pose: Pose, + tag: str, + ) -> None: + """ + Dump this pose to either a pdb file, or quiver file depending on the input arguments + """ + if self.pdb: + # If the outpdbdir does not exist, create it + # If there are parents in the path that do not exist, create them as well + if not os.path.exists(self.outpdbdir): + os.makedirs(self.outpdbdir) + + pdbfile = os.path.join(self.outpdbdir, tag + ".pdb") + pose.dump_pdb(pdbfile) + + def load_pose(self, tag: str) -> Pose: + """ + Load a pose from either a silent file, pdb file, or quiver file depending on the input arguments + """ + + if not self.pdb and not self.silent: + raise Exception("Neither pdb nor silent is set to True. Cannot load pose") + + pose = None + if self.pdb: + pose = Pose.from_pdb(tag) + + return pose diff --git a/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py b/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py new file mode 100644 index 000000000..30a27f632 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/proteinmpnn/util_protein_mpnn.py @@ -0,0 +1,1221 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Util functions for ProteinMPNN +""" + +import os +import copy +import pickle +from dataclasses import dataclass +from typing import List, Optional + +import mindspore as ms +import numpy as np +from mindspore import ops + +from .protein_mpnn import ProteinMPNN, _S_to_seq, _scores, tied_featurize + +num2aa = ["ALA","ARG","ASN","ASP","CYS","GLN","GLU","GLY","HIS","ILE", +"LEU","LYS","MET","PHE","PRO","SER","THR","TRP","TYR","VAL","UNK","MAS"] + +aa2num = {x: i for i, x in enumerate(num2aa)} + +# full sc atom representation (Nx14) +aa2long = [ + (" N "," CA "," C "," O "," CB ",None,None,None,None,None,None,None,None,None, + " H "," HA ","1HB ","2HB ","3HB ",None,None,None,None,None,None,None,None,), # ala + (" N "," CA "," C "," O "," CB "," CG "," CD "," NE "," CZ "," NH1"," NH2",None,None,None, + " H "," HA ","1HB ","2HB ","1HG ","2HG ","1HD ","2HD "," HE ","1HH1","2HH1","1HH2","2HH2",), # arg + (" N "," CA "," C "," O "," CB "," CG "," OD1"," ND2",None,None,None,None,None,None, + " H "," HA ","1HB ","2HB ","1HD2","2HD2",None,None,None,None,None,None,None,), # asn + (" N "," CA "," C "," O "," CB "," CG "," OD1"," OD2",None,None,None,None,None,None, + " H "," HA ","1HB ","2HB ",None,None,None,None,None,None,None,None,None,), # asp + (" N "," CA "," C "," O "," CB "," SG ",None,None,None,None,None,None,None,None, + " H "," HA ","1HB ","2HB "," HG ",None,None,None,None,None,None,None,None,), # cys + (" N "," CA "," C "," O "," CB "," CG "," CD "," OE1"," NE2",None,None,None,None,None, + " H "," HA ","1HB ","2HB ","1HG ","2HG ","1HE2","2HE2",None,None,None,None,None,), # gln + (" N "," CA "," C "," O "," CB "," CG "," CD "," OE1"," OE2",None,None,None,None,None, + " H "," HA ","1HB ","2HB ","1HG ","2HG ",None,None,None,None,None,None,None,), # glu + (" N "," CA "," C "," O ",None,None,None,None,None,None,None,None,None,None, + " H ","1HA ","2HA ",None,None,None,None,None,None,None,None,None,None,), # gly + (" N "," CA "," C "," O "," CB "," CG "," ND1"," CD2"," CE1"," NE2",None,None,None,None, + " H "," HA ","1HB ","2HB "," HD2"," HE1"," HE2",None,None,None,None,None,None,), # his + (" N "," CA "," C "," O "," CB "," CG1"," CG2"," CD1",None,None,None,None,None,None, + " H "," HA "," HB ","1HG2","2HG2","3HG2","1HG1","2HG1","1HD1","2HD1","3HD1",None,None,), # ile + (" N "," CA "," C "," O "," CB "," CG "," CD1"," CD2",None,None,None,None,None,None, + " H "," HA ","1HB ","2HB "," HG ","1HD1","2HD1","3HD1","1HD2","2HD2","3HD2",None,None,), # leu + (" N "," CA "," C "," O "," CB "," CG "," CD "," CE "," NZ ",None,None,None,None,None, + " H "," HA ","1HB ","2HB ","1HG ","2HG ","1HD ","2HD ","1HE ","2HE ","1HZ ","2HZ ","3HZ ",), # lys + (" N "," CA "," C "," O "," CB "," CG "," SD "," CE ",None,None,None,None,None,None, + " H "," HA ","1HB ","2HB ","1HG ","2HG ","1HE ","2HE ","3HE ",None,None,None,None,), # met + (" N "," CA "," C "," O "," CB "," CG "," CD1"," CD2"," CE1"," CE2"," CZ ",None,None,None, + " H "," HA ","1HB ","2HB "," HD1"," HD2"," HE1"," HE2"," HZ ",None,None,None,None,), # phe + (" N "," CA "," C "," O "," CB "," CG "," CD ",None,None,None,None,None,None,None, + " HA ","1HB ","2HB ","1HG ","2HG ","1HD ","2HD ",None,None,None,None,None,None,), # pro + (" N "," CA "," C "," O "," CB "," OG ",None,None,None,None,None,None,None,None, + " H "," HG "," HA ","1HB ","2HB ",None,None,None,None,None,None,None,None,), # ser + (" N "," CA "," C "," O "," CB "," OG1"," CG2",None,None,None,None,None,None,None, + " H "," HG1"," HA "," HB ","1HG2","2HG2","3HG2",None,None,None,None,None,None,), # thr + (" N "," CA "," C "," O "," CB "," CG "," CD1"," CD2"," NE1"," CE2"," CE3"," CZ2"," CZ3"," CH2", + " H "," HA ","1HB ","2HB "," HD1"," HE1"," HZ2"," HH2"," HZ3"," HE3",None,None,None,), # trp + (" N "," CA "," C "," O "," CB "," CG "," CD1"," CD2"," CE1"," CE2"," CZ "," OH ",None,None, + " H "," HA ","1HB ","2HB "," HD1"," HE1"," HE2"," HD2"," HH ",None,None,None,None,), # tyr + (" N "," CA "," C "," O "," CB "," CG1"," CG2",None,None,None,None,None,None,None, + " H "," HA "," HB ","1HG1","2HG1","3HG1","1HG2","2HG2","3HG2",None,None,None,None,), # val + (" N "," CA "," C "," O "," CB ",None,None,None,None,None,None,None,None,None, + " H "," HA ","1HB ","2HB ","3HB ",None,None,None,None,None,None,None,None,), # unk + (" N "," CA "," C "," O "," CB ",None,None,None,None,None,None,None,None,None, + " H "," HA ","1HB ","2HB ","3HB ",None,None,None,None,None,None,None,None,), # mask +] + +################################# +# Function Definitions +################################# + + +def my_rstrip(string, strip): + """ + Remove the trailing strip from a string. + + Args: + string (str): String. + strip (str): Strip. + + Returns: + str: String without the trailing strip. + """ + if string.endswith(strip): + return string[: -len(strip)] + return string + + +# PDB Parse Util Functions + +alpha_1 = list("ARNDCQEGHILKMFPSTWYV-") +states = len(alpha_1) +alpha_3 = ["ALA","ARG","ASN","ASP","CYS","GLN","GLU","GLY","HIS","ILE", + "LEU","LYS","MET","PHE","PRO","SER","THR","TRP","TYR","VAL","GAP"] + +aa_1_N = {a: n for n, a in enumerate(alpha_1)} +aa_3_N = {a: n for n, a in enumerate(alpha_3)} +aa_N_1 = dict(enumerate(alpha_1)) +aa_1_3 = dict(zip(alpha_1, alpha_3)) +aa_3_1 = dict(zip(alpha_3, alpha_1)) + + +def AA_to_N(x): + """ + Convert a sequence of amino acids to a sequence of indices. + + Args: + x (str or list of str): Sequence of amino acids. + + Returns: + list of int: Sequence of indices. + """ + # ["ARND"] -> [[0,1,2,3]] + x = np.array(x) + if x.ndim == 0: + x = x[None] + return [[aa_1_N.get(a, states - 1) for a in y] for y in x] + + +def N_to_AA(x): + """ + Convert a sequence of indices to a sequence of amino acids. + + Args: + x (list of int): Sequence of indices. + + Returns: + list of str: Sequence of amino acids. + """ + # [[0,1,2,3]] -> ["ARND"] + x = np.array(x) + if x.ndim == 1: + x = x[None] + return ["".join([aa_N_1.get(a, "-") for a in y]) for y in x] + +# End PDB Parse Util Functions + +def parse_PDB_biounits(x, atoms=None, chain=None): + """ + Parse a PDB file and extract the biounits. + + Args: + x (str): PDB filename. + atoms (list of str, optional): Atoms to extract. Default: ["N", "CA", "C"]. + chain (str, optional): Chain to extract. Default: None. + + Returns: + tuple: (length, atoms, coords=(x,y,z)), sequence + """ + if atoms is None: + atoms = ["N", "CA", "C"] + + xyz, seq, min_resn, max_resn = {}, {}, 1e6, -1e6 + with open(x, "r", encoding="utf-8") as fh: + for line in fh: + line = line.rstrip() + + if line[:6] == "HETATM" and line[17 : 17 + 3] == "MSE": + line = line.replace("HETATM", "ATOM ") + line = line.replace("MSE", "MET") + + if line[:4] == "ATOM": + ch = line[21:22] + if ch == chain or chain is None: + atom = line[12 : 12 + 4].strip() + resi = line[17 : 17 + 3] + resn = line[22 : 22 + 5].strip() + x, y, z = [float(line[i : (i + 8)]) for i in [30, 38, 46]] + + if resn[-1].isalpha(): + resa, resn = resn[-1], int(resn[:-1]) - 1 + else: + resa, resn = "", int(resn) - 1 + + min_resn = min(min_resn, resn) + max_resn = max(max_resn, resn) + if resn not in xyz: + xyz[resn] = {} + if resa not in xyz[resn]: + xyz[resn][resa] = {} + if resn not in seq: + seq[resn] = {} + if resa not in seq[resn]: + seq[resn][resa] = resi + + if atom not in xyz[resn][resa]: + xyz[resn][resa][atom] = np.array([x, y, z]) + + # convert to numpy arrays, fill in missing values + seq_, xyz_ = [], [] + try: + for resn in range(min_resn, max_resn + 1): + if resn in seq: + for k in sorted(seq[resn]): + seq_.append(aa_3_N.get(seq[resn][k], 20)) + else: + seq_.append(20) + if resn in xyz: + for k in sorted(xyz[resn]): + for atom in atoms: + if atom in xyz[resn][k]: + xyz_.append(xyz[resn][k][atom]) + else: + xyz_.append(np.full(3, np.nan)) + else: + for atom in atoms: + xyz_.append(np.full(3, np.nan)) + return np.array(xyz_).reshape(-1, len(atoms), 3), N_to_AA(np.array(seq_)) + except TypeError: + return "no_chain", "no_chain" + +def get_chain_alphabet(input_chain_list=None): + """ + Get the chain alphabet. + + Args: + input_chain_list (list of str, optional): List of chains. Default: None. + + Returns: + list of str: Chain alphabet. + """ + if input_chain_list: + return input_chain_list + init_alphabet = list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + extra_alphabet = [str(item) for item in list(np.arange(300))] + chain_alphabet = init_alphabet + extra_alphabet + return chain_alphabet + +def format4(x): + """ + Format a number to 4 decimal places. + + Args: + x (float): Number to format. + + Returns: + str: Formatted number. + """ + return np.format_float_positional(np.float32(x), unique=False, precision=4) + +def seq_with_slashes(seq, masked_chain_length_list, masked_list): + """ + Add slashes to a sequence. + + Args: + seq (str): Sequence. + masked_chain_length_list (list of int): List of masked chain lengths. + masked_list (list of bool): List of masked indices. + + Returns: + str: Sequence with slashes. + """ + start = 0 + end = 0 + list_of_AAs = [] + for mask_l in masked_chain_length_list: + end += mask_l + list_of_AAs.append(seq[start:end]) + start = end + seq = "".join(list(np.array(list_of_AAs)[np.argsort(masked_list)])) + l0 = 0 + for mc_length in list(np.array(masked_chain_length_list)[np.argsort(masked_list)])[:-1]: + l0 += mc_length + seq = seq[:l0] + "/" + seq[l0:] + l0 += 1 + return seq + +def forward_scores(model, X, S_in, mask, chain_M, chain_M_pos, residue_idx, chain_encoding_all, randn, + use_input_decoding_order=False, decoding_order=None): + """ + Forward pass of the model to get scores. + + Args: + model (ProteinMPNN): ProteinMPNN model. + X (Tensor): Input coordinates. + S_in (Tensor): Input sequence. + mask (Tensor): Mask. + chain_M (Tensor): Chain mask. + chain_M_pos (Tensor): Chain mask for positive examples. + residue_idx (Tensor): Residue indices. + chain_encoding_all (Tensor): Chain encodings. + randn (Tensor): Random noise. + use_input_decoding_order (bool, optional): Whether to use input decoding order. Default: False. + decoding_order (Tensor, optional): Decoding order. Default: None. + + Returns: + tuple: (scores, global_scores, log_probs, mask_for_loss) + """ + if use_input_decoding_order: + log_probs = model( + X, + S_in, + mask, + chain_M * chain_M_pos, + residue_idx, + chain_encoding_all, + randn, + use_input_decoding_order=True, + decoding_order=decoding_order, + ) + else: + log_probs = model( + X, + S_in, + mask, + chain_M * chain_M_pos, + residue_idx, + chain_encoding_all, + randn, + ) + mask_for_loss = mask * chain_M * chain_M_pos + scores_np = _scores(S_in, log_probs, mask_for_loss).data.numpy() + global_scores_np = _scores(S_in, log_probs, mask).data.numpy() + return scores_np, global_scores_np, log_probs, mask_for_loss + +def ensure_output_dirs(base_folder, save_score=False, score_only=False, + conditional_probs_only=False, unconditional_probs_only=False, + save_probs=False): + """ + Ensure the output directories exist. + + Args: + base_folder (str): Base folder. + save_score (bool, optional): Whether to save scores. Default: False. + score_only (bool, optional): Whether to save score only. Default: False. + conditional_probs_only (bool, optional): Whether to save conditional probabilities only. Default: False. + unconditional_probs_only (bool, optional): Whether to save unconditional probabilities only. Default: False. + save_probs (bool, optional): Whether to save probabilities. Default: False. + """ + if base_folder[-1] != "/": + base_folder = base_folder + "/" + if not os.path.exists(base_folder): + os.makedirs(base_folder) + if not os.path.exists(base_folder + "seqs"): + os.makedirs(base_folder + "seqs") + if save_score and not os.path.exists(base_folder + "scores"): + os.makedirs(base_folder + "scores") + if score_only and not os.path.exists(base_folder + "score_only"): + os.makedirs(base_folder + "score_only") + if conditional_probs_only and not os.path.exists(base_folder + "conditional_probs_only"): + os.makedirs(base_folder + "conditional_probs_only") + if unconditional_probs_only and not os.path.exists(base_folder + "unconditional_probs_only"): + os.makedirs(base_folder + "unconditional_probs_only") + if save_probs and not os.path.exists(base_folder + "probs"): + os.makedirs(base_folder + "probs") + return base_folder + + +def parse_PDB(x, atoms=None, chain=None): + """ + Parse a PDB file and extract the coordinates and sequence. + + Args: + x (str): PDB filename. + atoms (list of str, optional): Atoms to extract. Default: ["N", "CA", "C"]. + chain (str, optional): Chain to extract. Default: None. + + Returns: + tuple: (length, atoms, coords=(x,y,z)), sequence + """ + if not atoms: + atoms = ["N", "CA", "C"] + + xyz, seq, min_resn, max_resn = {}, {}, 1e6, -1e6 + with open(x, "r", encoding='utf-8') as fh: + for line in fh: + line = line.rstrip() + + if line[:6] == "HETATM" and line[17 : 17 + 3] == "MSE": + line = line.replace("HETATM", "ATOM ") + line = line.replace("MSE", "MET") + + if line[:4] == "ATOM": + ch = line[21:22] + if ch == chain or chain is None: + atom = line[12 : 12 + 4].strip() + resi = line[17 : 17 + 3] + resn = line[22 : 22 + 5].strip() + x, y, z = [float(line[i : (i + 8)]) for i in [30, 38, 46]] + + if resn[-1].isalpha(): + resa, resn = resn[-1], int(resn[:-1]) - 1 + else: + resa, resn = "", int(resn) - 1 + + min_resn = min(min_resn, resn) + max_resn = max(max_resn, resn) + if resn not in xyz: + xyz[resn] = {} + if resa not in xyz[resn]: + xyz[resn][resa] = {} + if resn not in seq: + seq[resn] = {} + if resa not in seq[resn]: + seq[resn][resa] = resi + + if atom not in xyz[resn][resa]: + xyz[resn][resa][atom] = np.array([x, y, z]) + + # convert to numpy arrays, fill in missing values + seq_, xyz_ = [], [] + for resn in range(min_resn, max_resn + 1): + if resn in seq: + for k in sorted(seq[resn]): + seq_.append(aa_3_N.get(seq[resn][k], 20)) + else: + seq_.append(20) + if resn in xyz: + for k in sorted(xyz[resn]): + for atom in atoms: + if atom in xyz[resn][k]: + xyz_.append(xyz[resn][k][atom]) + else: + xyz_.append(np.full(3, np.nan)) + else: + for atom in atoms: + xyz_.append(np.full(3, np.nan)) + return np.array(xyz_).reshape(-1, len(atoms), 3), N_to_AA(np.array(seq_)) + + +def generate_seqopt_features(pdbfile, chains): # multichain + """ + Generate sequence optimization features from a PDB file. + + Args: + pdbfile (str): PDB filename. + chains (list of str): Chains to extract. + + Returns: + dict: Sequence optimization features. + """ + my_dict = {} + concat_seq = "" + + for letter in chains: + xyz, seq = parse_PDB_biounits( + pdbfile, atoms=["N", "CA", "C", "O"], chain=letter + ) + + concat_seq += seq[0] + my_dict["seq_chain_" + letter] = seq[0] + coords_dict_chain = {} + coords_dict_chain["N_chain_" + letter] = xyz[:, 0, :].tolist() + coords_dict_chain["CA_chain_" + letter] = xyz[:, 1, :].tolist() + coords_dict_chain["C_chain_" + letter] = xyz[:, 2, :].tolist() + coords_dict_chain["O_chain_" + letter] = xyz[:, 3, :].tolist() + my_dict["coords_chain_" + letter] = coords_dict_chain + + my_dict["name"] = my_rstrip(pdbfile, ".pdb") + my_dict["num_of_chains"] = len(chains) + my_dict["seq"] = concat_seq + + return my_dict + + +def get_seq_from_pdb(pdb_fn, slash_for_chainbreaks): + """ + Get the sequence from a PDB file. + + Args: + pdb_fn (str): PDB filename. + slash_for_chainbreaks (bool): Whether to use slash for chain breaks. + + Returns: + str: Sequence. + """ + to1letter = { + "ALA": "A", + "ARG": "R", + "ASN": "N", + "ASP": "D", + "CYS": "C", + "GLN": "Q", + "GLU": "E", + "GLY": "G", + "HIS": "H", + "ILE": "I", + "LEU": "L", + "LYS": "K", + "MET": "M", + "PHE": "F", + "PRO": "P", + "SER": "S", + "THR": "T", + "TRP": "W", + "TYR": "Y", + "VAL": "V", + } + + seq = "" + with open(pdb_fn, "r", encoding='utf-8') as fp: + for line in fp: + if line.startswith("TER"): + if not slash_for_chainbreaks: + continue + seq += "/" + if not line.startswith("ATOM"): + continue + if line[12:16].strip() != "CA": + continue + resName = line[17:20] + + seq += to1letter[resName] + return my_rstrip(seq, "/") + + +def init_seq_optimize_model( + hidden_dim, num_layers, backbone_noise, num_connections, checkpoint_path +): + """ + Initialize the sequence optimization model. + + Args: + hidden_dim (int): Hidden dimension. + num_layers (int): Number of layers. + backbone_noise (float): Backbone noise. + num_connections (int): Number of connections. + checkpoint_path (str): Checkpoint path. + + Returns: + ProteinMPNN: Sequence optimization model. + """ + model = ProteinMPNN( + num_letters=21, + node_features=hidden_dim, + edge_features=hidden_dim, + hidden_dim=hidden_dim, + num_encoder_layers=num_layers, + num_decoder_layers=num_layers, + augment_eps=backbone_noise, + k_neighbors=num_connections, + ) + with open(checkpoint_path, "rb") as f: + checkpoint = pickle.load(f) + model.load_state_dict(checkpoint["model_state_dict"]) + model.set_train(False) + + return model + + +def set_default_args(seq_per_target, omit_AAs=None): + """ + Set default arguments for sequence optimization. + + Args: + seq_per_target (int): Number of sequences per target. + omit_AAs (list of str, optional): AAs to omit. Default: ["X"]. + + Returns: + dict: Default arguments. + """ + if omit_AAs is None: + omit_AAs = ["X"] + + if "X" not in omit_AAs: + omit_AAs.append("X") # We don't want any unknown residue assignments + + retval = {} + retval["BATCH_COPIES"] = min(1, seq_per_target) + retval["NUM_BATCHES"] = seq_per_target // retval["BATCH_COPIES"] + retval["temperature"] = 0.1 + + omit_AAs_list = omit_AAs + alphabet = "ACDEFGHIKLMNPQRSTVWYX" + retval["omit_AAs_np"] = np.array([AA in omit_AAs_list for AA in alphabet]).astype( + np.float32 + ) + + retval["omit_AA_dict"] = None + retval["pssm_dict"] = None + retval["bias_AA_dict"] = None + retval["tied_positions_dict"] = None + retval["bias_by_res_dict"] = None + retval["bias_AAs_np"] = np.zeros(len(alphabet)) + + return retval + + +def generate_sequences( + model, + feature_dict, + arg_dict, + masked_chains, + visible_chains, + fixed_positions_dict=None, +): + """ + Generate sequences using the sequence optimization model. + + Args: + model (ProteinMPNN): Sequence optimization model. + feature_dict (dict): Feature dictionary. + arg_dict (dict): Argument dictionary. + masked_chains (list of str): Masked chains. + visible_chains (list of str): Visible chains. + fixed_positions_dict (dict, optional): Fixed positions dictionary. Default: None. + + Returns: + list of tuple: List of sequences and scores. + """ + seqs_scores = [] + + batch_clones = [ + copy.deepcopy(feature_dict) for i in range(arg_dict["BATCH_COPIES"]) + ] + chain_id_dict = { + feature_dict["name"]: (masked_chains, visible_chains) + } # Masked, visible is the order, I think - Nate + + ( + X, + S, + mask, + _, + chain_M, + chain_encoding_all, + _, + _, + _, + _, + chain_M_pos, + omit_AA_mask, + residue_idx, + _, + _, + pssm_coef, + pssm_bias, + pssm_log_odds_all, + bias_by_res_all, + _, + ) = tied_featurize( + batch_clones, + chain_id_dict, + fixed_positions_dict, + arg_dict["omit_AA_dict"], + arg_dict["tied_positions_dict"], + arg_dict["pssm_dict"], + arg_dict["bias_by_res_dict"], + ) + + pssm_threshold = 0 # Nate is hardcoding this + pssm_log_odds_mask = ( + pssm_log_odds_all > pssm_threshold + ).float() # 1.0 for true, 0.0 for false + + randn_1 = ms.mint.randn(chain_M.shape) + log_probs = model( + X, S, mask, chain_M * chain_M_pos, residue_idx, chain_encoding_all, randn_1 + ) + mask_for_loss = mask * chain_M * chain_M_pos + scores = _scores(S, log_probs, mask_for_loss) + + for _ in range(arg_dict["NUM_BATCHES"]): + randn_2 = ms.mint.randn(chain_M.shape) + + sample_dict = model.sample( + X, + randn_2, + S, + chain_M, + chain_encoding_all, + residue_idx, + mask=mask, + temperature=arg_dict["temperature"], + omit_AAs_np=arg_dict["omit_AAs_np"], + bias_AAs_np=arg_dict["bias_AAs_np"], + chain_M_pos=chain_M_pos, + omit_AA_mask=omit_AA_mask, + pssm_coef=pssm_coef, + pssm_bias=pssm_bias, + pssm_multi=0, + pssm_log_odds_flag=False, + pssm_log_odds_mask=pssm_log_odds_mask, + pssm_bias_flag=False, + bias_by_res=bias_by_res_all, + ) + + S_sample = sample_dict["S"] + + # Compute scores + log_probs = model( + X, S, mask, chain_M * chain_M_pos, residue_idx, chain_encoding_all, randn_2 + ) + mask_for_loss = mask * chain_M * chain_M_pos + scores = _scores(S_sample, log_probs, mask_for_loss) + scores = scores.data.numpy() + + for b_ix in range(arg_dict["BATCH_COPIES"]): + seq = _S_to_seq(S_sample[b_ix], chain_M[b_ix]) + score = scores[b_ix] + + seqs_scores.append((seq, score)) + + return seqs_scores + + +@dataclass +class Pose: + """ + A class to represent a protein pose. + + Attributes: + atoms: + A [L, 3, 3] tensor of backbone atom coordinates + seq: + A [L] tensor of amino acid residues in 3 letter format + chain: + A [L] tensor of chain identifiers + cdr_dict: + A dictionary of CDR indices, with indices starting at 1 + """ + + atoms: np.ndarray # [L, 3, 3] tensor of backbone atom coordinates + seq: np.ndarray # [L] tensor of amino acid residues in 3 letter format + chain: np.ndarray # [L] tensor of chain identifiers + + cdr_dict: dict[ + str, list[int] + ] # dictionary of CDR indices, with indices starting at 1 + + @classmethod + def from_pdb(cls, pdbfile: str) -> "Pose": + """ + Load a pdb file into a Pose object + + Args: + pdbfile: + The path to the pdb file to load + """ + + with open(pdbfile, "r", encoding='utf-8') as f: + pdblines = f.readlines() + + return cls.from_pdblines(pdblines) + + @classmethod + def from_pdblines(cls, pdblines: List[str]) -> "Pose": + """ + Create a Pose object from a list of pdb lines + + Args: + pdblines: + A list of pdb lines to parse + """ + + seq, pdb_idx, xyz = parse_pdblines(pdblines) + + # Parse to a backbone xyz tensor + bb_xyz = xyz[:, :4, :] # [L, 4, 3] + + # Convert the sequence from numbers to 3 letter amino acids + seq = np.array([num2aa[i] for i in seq]) + + # Get the chain identifiers, pdb_idx is a list of tuples (chain, resnum) + chains = np.array([i[0] for i in pdb_idx]) + + cdr_masks = get_cdr_masks_from_remarks(pdb_idx, pdblines) + + # Now turn the cdr_masks into a dict of cdr indices + cdr_dict = { + "H1": [], + "H2": [], + "H3": [], + "L1": [], + "L2": [], + "L3": [], + } + + for cdr, mask in cdr_masks.items(): + cdr_dict[cdr] = np.where(mask)[0].tolist() + + return cls( + atoms=bb_xyz, + seq=seq, + chain=chains, + cdr_dict=cdr_dict, + ) + + def assert_HLT(self) -> bool: + """ + Check if the pose is currently in HLT order. + + Returns: + True if the pose is in HLT order, False otherwise. + """ + + # We will collect the consecutive chains in the pose + + # Find the indices where the value changes + change_indices = np.where(np.diff(self.chains) != 0)[0] + 1 + + # Include the first element as it is always unique in this context + unique_indices = np.insert(change_indices, 0, 0) + + # Get the consecutive unique chains + unique_chains = self.chains[unique_indices] + + # Check two things about these chains: + # 1. The chains must be unique ie. there are no dis-contiguous chains + # 2. The chains must be in the order H, L, T. Here, either H or L but not both can be missing, + # but T must be present + + # Check 1 + if np.unique(unique_chains).size != unique_chains.size: + return False + + # Check 2 + if "T" not in unique_chains: + return False + + if "H" in unique_chains and "L" in unique_chains: + return unique_chains == np.array(["H", "L", "T"]) + + if "H" in unique_chains and "L" not in unique_chains: + return unique_chains == np.array(["H", "T"]) + + if "H" not in unique_chains and "L" in unique_chains: + return unique_chains == np.array(["L", "T"]) + + # If we get here something has gone wrong + raise Exception(f"Unsupported combination of chains: {unique_chains} provided") + + def mutate_residue( + self, + chain: str, + residx: int, + newres: str, + ) -> None: + """ + Mutate a residue in a pose + + Args: + chain: + The chain identifier of the residue to mutate + + residx: + The zero-indexed residue index of the residue to mutate + + newres: + The new 3 letter residue name to assign to the specified residue + """ + + # Assert that the residue index is within the bounds of the chain + assert self.chain[residx] == chain, ( + "Residue index is not in the specified chain" + ) + + # Assert that the new residue is a valid amino acid + assert newres in num2aa, "Invalid amino acid" + + # Assign the new residue to the sequence + self.seq[residx] = newres + + def dump_pdb(self, pdbfile: str) -> None: + """ + Dump a Pose object to a pdb file + + Args: + pdbfile: + The path to the pdb file to write + """ + + pdblines = self.to_pdblines() + + with open(pdbfile, "w", encoding='utf-8') as f: + f.writelines(pdblines) + + def to_pdblines(self) -> List[str]: + """ + Convert a pose to a list of pdb lines + + Returns: + A list of pdb lines representing the pose + """ + + # Convert the sequence back to numbers + seq = np.array([aa2num[i] for i in self.seq]) + + pdblines = ab_write_pdblines( + atoms=self.atoms, + seq=seq, + chain_idx=self.chain, + loop_map=self.cdr_dict, + ) + + return pdblines + + +def stamp_pdbline( + prefix: str, + ctr: int, + atom_name: str, + residue_name: str, + chain: str, + residue_idx: int, + x_coord: float, + y_coord: float, + z_coord: float, + occupancy: float, + b_factor: float, +) -> str: + """ + Args: + prefix: + The prefix to use for the pdb line, e.g. "ATOM" or "HETATM" + ctr: + The atom counter to use for the pdb line + atom_name: + The name of the atom, e.g. " CA " + residue_name: + The name of the residue, e.g. "ALA" + chain: + The chain identifier, e.g. "A" + residue_idx: + The zero-indexed residue index, e.g. 1 + x_coord: + The x coordinate of the atom, e.g. 1.0 + y_coord: + The y coordinate of the atom, e.g. 2.0 + z_coord: + The z coordinate of the atom, e.g. 3.0 + occupancy: + The occupancy of the atom, e.g. 1.0 + b_factor: + The B-factor of the atom, e.g. 0.0 + """ + return f"{prefix:<6s}{ctr:>5d} {atom_name:>4s} {residue_name:<3s} {chain}{residue_idx.item():>4d} " + \ + f"{x_coord.item():>8.3f}{y_coord.item():>8.3f}{z_coord.item():>8.3f}{occupancy:>6.2f}{b_factor:>6.2f}\n" + + +def ab_write_pdblines( + atoms: np.ndarray, + seq: np.ndarray, + chain_idx: np.ndarray, + idx_pdb: Optional[np.ndarray] = None, + bfacts: Optional[np.ndarray] = None, + loop_map: dict[str, List[int]] = None, +) -> List[str]: + """ + Given a set of atomic coordinates and a sequence, generate a list of PDB lines + describing the structure. + + Args: + atoms: + A [L, N, 3] tensor of atomic coordinates, where N can be 1, 3, 4, 14, or 27 + seq: + A [L] tensor of integer amino acid residues + chain_idx: + A [L] tensor of chain indices + num2aa: + The way to convert from residue numbers to amino acid residues + idx_pdb: + A [L] tensor of residue indices + bfacts: + A [L] tensor of B-factors + loop_map: + A dictionary mapping loop names to lists of residue indices + """ + if not loop_map: + loop_map = {} + + ctr = 1 + if bfacts is None: + bfacts = ms.mint.zeros(atoms.shape[0]) + if idx_pdb is None: + # Default to 1-indexed residue numbers + idx_pdb = 1 + ms.mint.arange(atoms.shape[0]) + + Bfacts = np.clip( + bfacts, + a_min=0, + a_max=1, + ) + + pdblines = [] + for i in range(seq.shape[0]): + chain = chain_idx[i] + + # If the input is a single set of atomic coordinates, assume it is a C-alpha trace + if len(atoms.shape) == 2: + pdblines.append( + stamp_pdbline( + prefix="ATOM", + ctr=ctr, + atom_name=" CA ", + residue_name=num2aa[seq[i]], + chain=chain, + residue_idx=idx_pdb[i], + x_coord=atoms[i, 0], + y_coord=atoms[i, 1], + z_coord=atoms[i, 2], + occupancy=1.0, + b_factor=Bfacts[i], + ) + ) + + ctr += 1 + + # If the input is a set of atomic coordinates with 3 atoms per residue, + # assume it is a backbone trace + elif atoms.shape[1] == 3: + for j, atm_j in enumerate([" N ", " CA ", " C "]): + pdblines.append( + stamp_pdbline( + prefix="ATOM", + ctr=ctr, + atom_name=atm_j, + residue_name=num2aa[seq[i]], + chain=chain, + residue_idx=idx_pdb[i], + x_coord=atoms[i, j, 0], + y_coord=atoms[i, j, 1], + z_coord=atoms[i, j, 2], + occupancy=1.0, + b_factor=Bfacts[i], + ) + ) + + ctr += 1 + + # If the input is a set of atomic coordinates with 4 atoms per residue, + # assume it is a backbone trace with an oxygen atom + elif atoms.shape[1] == 4: + for j, atm_j in enumerate([" N ", " CA ", " C ", " O "]): + pdblines.append( + stamp_pdbline( + prefix="ATOM", + ctr=ctr, + atom_name=atm_j, + residue_name=num2aa[seq[i]], + chain=chain, + residue_idx=idx_pdb[i], + x_coord=atoms[i, j, 0], + y_coord=atoms[i, j, 1], + z_coord=atoms[i, j, 2], + occupancy=1.0, + b_factor=Bfacts[i], + ) + ) + + ctr += 1 + + # Otherwise, assume the input is a full atomic tensor with either 14 or 27 atoms per residue + else: + natoms = atoms.shape[1] + + assert natoms in (14, 27), ( + "Invalid number of atoms per residue, must be 14 or 27" + ) + + atms = aa2long[aa2num[seq[i]]] + + # his prot hack + if aa2num[seq[i]] == 8 and ops.norm(atoms[i, 9, :] - atoms[i, 5, :]) < 1.7: + atms = ( + " N ", " CA ", " C ", " O ", + " CB ", " CG ", " NE2", " CD2", + " CE1", " ND1", None, None, + None, None, " H ", " HA ", + "1HB ", "2HB ", " HD2", " HE1", + " HD1", None, None, None, + None, None, None, + ) # his_d + + for j, atm_j in enumerate(atms): + if j < natoms and atm_j is not None: + pdblines.append( + stamp_pdbline( + prefix="ATOM", + ctr=ctr, + atom_name=atm_j, + residue_name=seq[i], + chain=chain, + residue_idx=idx_pdb[i], + x_coord=atoms[i, j, 0], + y_coord=atoms[i, j, 1], + z_coord=atoms[i, j, 2], + occupancy=1.0, + b_factor=Bfacts[i], + ) + ) + ctr += 1 + + # This may or may not be necessary between the coordinates and the REMARKS + pdblines.append("TER\n") + + # Add in labels for loop locations in the output structure + # NB: could also add in the hotspots labels as remarks here as well + for loop in loop_map: + for resi in loop_map[loop]: + pdblines.append(f"REMARK PDBinfo-LABEL:{resi:5d} {loop}\n") + + return pdblines + + +def parse_pdblines(lines: list[str]) -> tuple[ms.Tensor, list, ms.Tensor]: + """ + Parses PDB lines to extract sequence, pdb_idx, and XYZ coordinates. + + Args: + lines: + A list of PDB lines, where each line is a string. + + Returns: + seq: + A tensor of shape (N,) containing the sequence indices, where N is the number of residues. + pdb_idx: + A list of tuples (chain, resi) for each residue, where chain is a string and resi is an integer. + xyz: + A tensor of shape (N, 27, 3) containing the XYZ coordinates for each atom in each residue. + """ + res = [ + (l[22:26], l[17:20]) + for l in lines + if l[:4] == "ATOM" and l[12:16].strip() == "CA" + ] + seq = ms.tensor([aa2num[r[1]] if r[1] in aa2num else 20 for r in res]) + + # Generating pdb_idx for indexing + pdb_idx = [ + (l[21:22].strip(), int(l[22:26].strip())) + for l in lines + if l[:4] == "ATOM" and l[12:16].strip() == "CA" + ] + + # Creating a tensor for XYZ coordinates + xyz = ms.mint.full((len(res), 27, 3), float("nan"), dtype=ms.float32) + + # A dictionary to quickly find the index in pdb_idx (for efficiency) + pdb_idx_lookup = {k: i for i, k in enumerate(pdb_idx)} + + for l in lines: + if l[:4] == "ATOM": + chain, resNo, atom, aa = ( + l[21:22].strip(), + int(l[22:26]), + " " + l[12:16].strip().ljust(3), + l[17:20], + ) + if (chain, resNo) in pdb_idx_lookup: + idx = pdb_idx_lookup[(chain, resNo)] + if aa in aa2num: # Ensure aa is known + for i_atm, tgtatm in enumerate(aa2long[aa2num[aa]]): + if ( + tgtatm is not None and tgtatm.strip() == atom.strip() + ): # Matching atom name + xyz[idx, i_atm, :] = ms.tensor( + [float(l[30:38]), float(l[38:46]), float(l[46:54])], + dtype=ms.float32, + ) + break + + return seq, pdb_idx, xyz + + +def split_remark(line: str) -> tuple[str, int]: + """ + Splits a remark line into loop name and residue index. + + Args: + line: + A string line from the PDB file, starting with "REMARK PDBinfo-LABEL". + + Returns: + loop: + A string representing the loop name, e.g. "H1", "H2", "H3", "L1", "L2", "L3". + resi: + An integer representing the residue index. + """ + return line.split()[3][0], int(line.split()[2]) + + +def get_cdr_masks_from_remarks(pdb_idx: list, lines: list[str]) -> dict: + """ + Extracts CDR masks from PDB remarks. + + Args: + pdb_idx: + A list of tuples (chain, resi) for each residue, where chain is a string and resi is an integer. + lines: + A list of PDB lines, where each line is a string. + + Returns: + cdr_masks: + A dictionary with keys "H1", "H2", "H3", "L1", "L2", "L3" and values of boolean masks for each CDR loop. + """ + cdr_pdb_idx = [] + cdr_names = ["H1", "H2", "H3", "L1", "L2", "L3"] + cdr_masks = {loop: ms.mint.zeros(len(pdb_idx)).bool() for loop in cdr_names} + for l in lines: + if l.startswith("REMARK PDBinfo-LABEL"): + l = l.strip() + cdr_pdb_idx.append(split_remark(l)) + loop = l[27:29].upper() + if loop in cdr_names: + resi = int(l[21:26]) - 1 # Loop residues in HLT are 1-indexed + cdr_masks[loop][resi] = True + if ms.mint.any(ms.mint.stack(list(cdr_masks.values())), dim=0).sum() != len( + cdr_pdb_idx + ): + raise ValueError("Not all cdr residues found in file. Remark indexing is bad") + return cdr_masks diff --git a/MindSPONGE/applications/proteinmpnn/proteinmpnn_interface_design.py b/MindSPONGE/applications/proteinmpnn/proteinmpnn_interface_design.py new file mode 100644 index 000000000..5cdb25c8e --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/proteinmpnn_interface_design.py @@ -0,0 +1,267 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Interface Design with ProteinMPNN +""" + +import argparse +import os +import sys +import time + +import proteinmpnn.util_protein_mpnn as mpnn_util +from proteinmpnn.sample_features import SampleFeatures +from proteinmpnn.struct_manager import StructManager + +################################# +# Parse Arguments +################################# + +parser = argparse.ArgumentParser() + +# I/O Arguments +parser.add_argument( + "-pdbdir", + type=str, + default="", + help="The name of a directory of pdbs to run through the model", +) +parser.add_argument( + "-outpdbdir", + type=str, + default="outputs", + help="The directory to which the output PDB files will be written", +) +parser.add_argument( + "-runlist", + type=str, + default="", + help="The path of a list of pdb tags to run (default: ''; Run all PDBs", +) +parser.add_argument( + "-checkpoint_name", + type=str, + default="check.point", + help="The name of a file where tags which have finished will be written (default: check.point)", +) +parser.add_argument( + "-debug", + action="store_true", + default=False, + help="When active, errors will cause the script to crash and the error message " + + "to be printed out (default: False)", +) + +# Design Arguments +parser.add_argument( + "-loop_string", + type=str, + default="H1,H2,H3,L1,L2,L3", + help="The list of loops which you wish to design", +) +parser.add_argument( + "-seqs_per_struct", + type=int, + default="1", + help="The number of sequences to generate for each structure (default: 1)", +) + +# ProteinMPNN Specific Arguments +default_ckpt = os.path.join( + os.path.dirname(__file__), "./weights/vanilla_model_weights/v_48_020.ckpt" +) +parser.add_argument("-checkpoint_path", type=str, default=default_ckpt) +parser.add_argument( + "-temperature", + type=float, + default=0.000001, + help="An a3m file containing the MSA of your target", +) +parser.add_argument( + "-augment_eps", + type=float, + default=0, + help="The variance of random noise to add to the atomic coordinates (default 0)", +) +parser.add_argument( + "-protein_features", + type=str, + default="full", + help="What type of protein features to input to ProteinMPNN (default: full)", +) +parser.add_argument( + "-omit_AAs", + type=str, + default="CX", + help="A string of all residue types (one letter case-insensitive) that you would not like to " + + "use for design. Letters not corresponding to residue types will be ignored", +) +parser.add_argument( + "-num_connections", + type=int, + default=48, + help="Number of neighbors each residue is connected to, default 48, higher number leads to " + + "better interface design but will cost more to run the model.", +) + +args = parser.parse_args(sys.argv[1:]) + + +class ProteinMPNN_runner: + """ + This class is designed to run the ProteinMPNN model on a single input. This class handles the loading of the model, + the loading of the input data, the running of the model, and the processing of the output + """ + + def __init__(self, args, struct_manager): + self.struct_manager = struct_manager + + self.mpnn_model = mpnn_util.init_seq_optimize_model( + hidden_dim=128, + num_layers=3, + backbone_noise=args.augment_eps, + num_connections=args.num_connections, + checkpoint_path=args.checkpoint_path, + ) + + self.temperature = args.temperature + self.seqs_per_struct = args.seqs_per_struct + self.omit_AAs = [ + letter + for letter in args.omit_AAs.upper() + if letter in list("ARNDCQEGHILKMFPSTWYVX") + ] + + def sequence_optimize( + self, sample_feats: SampleFeatures + ) -> list[tuple[str, float]]: + """ + Run ProteinMPNN sequence optimization on the pose + + Args: + sample_feats: + A SampleFeatures object containing the pose, chains, fixed_res, and tag + + Returns: + A list of tuples, where each tuple contains a sequence and its score + """ + t0 = time.time() + + # Once we have figured out pose I/O without Rosetta this will be easy to swap in + pdbfile = "temp.pdb" + sample_feats.pose.dump_pdb(pdbfile) + + feature_dict = mpnn_util.generate_seqopt_features(pdbfile, sample_feats.chains) + + os.remove(pdbfile) + + arg_dict = mpnn_util.set_default_args( + self.seqs_per_struct, omit_AAs=self.omit_AAs + ) + arg_dict["temperature"] = self.temperature + + masked_chains = sample_feats.chains[:-1] + visible_chains = [sample_feats.chains[-1]] + + fixed_positions_dict = {pdbfile[: -len(".pdb")]: sample_feats.fixed_res} + + sequences = mpnn_util.generate_sequences( + self.mpnn_model, + feature_dict, + arg_dict, + masked_chains, + visible_chains, + fixed_positions_dict=fixed_positions_dict, + ) + + print( + f"MPNN generated {len(sequences)} sequences in {int(time.time() - t0)} seconds" + ) + + print(f"sequence_optimize: {sequences}") + + return sequences + + def proteinmpnn(self, sample_feats: SampleFeatures) -> None: + """ + Run MPNN sequence optimization on the pose + """ + seqs_scores = self.sequence_optimize(sample_feats) + + # Iterate though each seq score pair and thread the sequence onto the pose + # Then write each pose to a pdb file + prefix = f"{sample_feats.tag}_dldesign" + for idx, (seq, _) in enumerate(seqs_scores): + sample_feats.thread_mpnn_seq(seq) + + outtag = f"{prefix}_{idx}" + + self.struct_manager.dump_pose(sample_feats.pose, outtag) + + def run_model(self, tag, args): + """ + Run ProteinMPNN on the pose + """ + t0 = time.time() + + print(f"Attempting pose: {tag}") + + # Load the pose + pose = self.struct_manager.load_pose(tag) + + # Initialize the features + sample_feats = SampleFeatures(pose, tag) + + # Parse the loop string and determine which residues should be designed + sample_feats.loop_string2fixed_res(args.loop_string) + + self.proteinmpnn(sample_feats) + + seconds = int(time.time() - t0) + + print(f"Struct: {pdb} reported success in {seconds} seconds") + + +#################### +####### Main ####### +#################### + +struct_manager = StructManager(args) +proteinmpnn_runner = ProteinMPNN_runner(args, struct_manager) + +for pdb in struct_manager.iterate(): + if args.debug: + proteinmpnn_runner.run_model(pdb, args) + + else: # When not in debug mode the script will continue to run even when some poses fail + t0 = time.time() + + try: + proteinmpnn_runner.run_model(pdb, args) + + except KeyboardInterrupt: + sys.exit("Script killed by Control+C, exiting") + + except Exception: + seconds = int(time.time() - t0) + print( + f"Struct with tag {pdb} failed in {seconds} seconds with error: {sys.exc_info()[0]}" + ) + + # We are done with one pdb, record that we finished + struct_manager.record_checkpoint(pdb) diff --git a/MindSPONGE/applications/proteinmpnn/proteinmpnn_run.py b/MindSPONGE/applications/proteinmpnn/proteinmpnn_run.py new file mode 100644 index 000000000..910a49887 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/proteinmpnn_run.py @@ -0,0 +1,750 @@ +# Modified from ProteinMPNN (https://github.com/dauparas/ProteinMPNN) +# Original license: MIT License +# +# Copyright 2025 Huawei Technologies Co., Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +""" +Run ProteinMPNN +""" + +import argparse +import copy +import json +import os +import os.path +import pickle +import random +import sys +import time + +import mindspore as ms +import numpy as np + +from proteinmpnn.protein_mpnn import (ProteinMPNN, StructureDataset, + StructureDatasetPDB, _S_to_seq, + parse_fasta, parse_PDB, tied_featurize) +from proteinmpnn.util_protein_mpnn import ( + format4, + seq_with_slashes, + forward_scores, + ensure_output_dirs, +) + +argparser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter +) + +argparser.add_argument( + "--suppress_print", type=int, default=0, help="0 for False, 1 for True" +) + +argparser.add_argument( + "--ca_only", + action="store_true", + default=False, + help="Parse CA-only structures and use CA-only models (default: false)", +) +argparser.add_argument( + "--path_to_model_weights", + type=str, + default="", + help="Path to model weights folder;", +) +argparser.add_argument( + "--model_name", + type=str, + default="v_48_020", + help="ProteinMPNN model name: v_48_002, v_48_010, v_48_020, v_48_030; v_48_010=version with 48 edges 0.10A noise", +) +argparser.add_argument( + "--use_soluble_model", + action="store_true", + default=False, + help="Flag to load ProteinMPNN weights trained on soluble proteins only.", +) + +argparser.add_argument( + "--seed", + type=int, + default=0, + help="If set to 0 then a random seed will be picked;", +) + +argparser.add_argument( + "--save_score", + type=int, + default=0, + help="0 for False, 1 for True; save score=-log_prob to npy files", +) +argparser.add_argument( + "--save_probs", + type=int, + default=0, + help="0 for False, 1 for True; save MPNN predicted probabilities per position", +) + +argparser.add_argument( + "--score_only", + type=int, + default=0, + help="0 for False, 1 for True; score input backbone-sequence pairs", +) +argparser.add_argument( + "--path_to_fasta", + type=str, + default="", + help="score provided input sequence in a fasta format; \ + e.g. GGGGGG/PPPPS/WWW for chains A, B, C sorted alphabetically and separated by /", +) + +argparser.add_argument( + "--conditional_probs_only", + type=int, + default=0, + help="0 for False, 1 for True; output conditional probabilities p(s_i given the rest of the sequence and backbone)", +) +argparser.add_argument( + "--conditional_probs_only_backbone", + type=int, + default=0, + help="0 for False, 1 for True; if true output conditional probabilities p(s_i given backbone)", +) +argparser.add_argument( + "--unconditional_probs_only", + type=int, + default=0, + help="0 for False, 1 for True; output unconditional probabilities p(s_i given backbone) in one forward pass", +) + +argparser.add_argument( + "--backbone_noise", + type=float, + default=0.00, + help="Standard deviation of Gaussian noise to add to backbone atoms", +) +argparser.add_argument( + "--num_seq_per_target", + type=int, + default=1, + help="Number of sequences to generate per target", +) +argparser.add_argument( + "--batch_size", + type=int, + default=1, + help="Batch size; can set higher for titan, quadro GPUs, reduce this if running out of GPU memory", +) +argparser.add_argument( + "--max_length", type=int, default=200000, help="Max sequence length" +) +argparser.add_argument( + "--sampling_temp", + type=str, + default="0.1", + help="A string of temperatures, 0.2 0.25 0.5. Sampling temperature for amino acids. \ + Suggested values 0.1, 0.15, 0.2, 0.25, 0.3. Higher values will lead to more diversity.", +) + +argparser.add_argument( + "--out_folder", + type=str, + help="Path to a folder to output sequences, e.g. /home/out/", +) +argparser.add_argument( + "--pdb_path", type=str, default="", help="Path to a single PDB to be designed" +) +argparser.add_argument( + "--pdb_path_chains", + type=str, + default="", + help="Define which chains need to be designed for a single PDB ", +) +argparser.add_argument( + "--jsonl_path", type=str, help="Path to a folder with parsed pdb into jsonl" +) +argparser.add_argument( + "--chain_id_jsonl", + type=str, + default="", + help="Path to a dictionary specifying which chains need to be designed and which ones are fixed, \ + if not specified all chains will be designed.", +) +argparser.add_argument( + "--fixed_positions_jsonl", + type=str, + default="", + help="Path to a dictionary with fixed positions", +) +argparser.add_argument( + "--omit_AAs", + type=list, + default="X", + help="Specify which amino acids should be omitted in the generated sequence, \ + e.g. 'AC' would omit alanine and cystine.", +) +argparser.add_argument( + "--bias_AA_jsonl", + type=str, + default="", + help="Path to a dictionary which specifies AA composion bias if neededi, \ + e.g. {A: -1.1, F: 0.7} would make A less likely and F more likely.", +) + +argparser.add_argument( + "--bias_by_res_jsonl", + default="", + help="Path to dictionary with per position bias.", +) +argparser.add_argument( + "--omit_AA_jsonl", + type=str, + default="", + help="Path to a dictionary which specifies which amino acids need to be omitted \ + from design at specific chain indices", +) +argparser.add_argument( + "--pssm_jsonl", type=str, default="", help="Path to a dictionary with pssm" +) +argparser.add_argument( + "--pssm_multi", + type=float, + default=0.0, + help="A value between [0.0, 1.0], 0.0 means do not use pssm, 1.0 ignore MPNN predictions", +) +argparser.add_argument( + "--pssm_threshold", + type=float, + default=0.0, + help="A value between -inf + inf to restric per position AAs", +) +argparser.add_argument( + "--pssm_log_odds_flag", type=int, default=0, help="0 for False, 1 for True" +) +argparser.add_argument( + "--pssm_bias_flag", type=int, default=0, help="0 for False, 1 for True" +) + +argparser.add_argument( + "--tied_positions_jsonl", + type=str, + default="", + help="Path to a dictionary with tied positions", +) + +args = argparser.parse_args() + +if args.seed: + seed = args.seed +else: + seed = int(np.random.randint(0, high=999, size=1, dtype=int)[0]) + +ms.manual_seed(seed) +random.seed(seed) +np.random.seed(seed) + +hidden_dim = 128 +num_layers = 3 + +if args.path_to_model_weights: + model_folder_path = args.path_to_model_weights + if model_folder_path[-1] != "/": + model_folder_path = model_folder_path + "/" +else: + file_path = os.path.realpath(__file__) + k = file_path.rfind("/") + if args.ca_only: + print("Using CA-ProteinMPNN!") + model_folder_path = file_path[:k] + "/weights/ca_model_weights/" + if args.use_soluble_model: + print("WARNING: CA-SolubleMPNN is not available yet") + sys.exit() + else: + if args.use_soluble_model: + print("Using ProteinMPNN trained on soluble proteins only!") + model_folder_path = file_path[:k] + "/weights/soluble_model_weights/" + else: + model_folder_path = file_path[:k] + "/weights/vanilla_model_weights/" + +checkpoint_path = model_folder_path + f"{args.model_name}.ckpt" +folder_for_outputs = args.out_folder + +NUM_BATCHES = args.num_seq_per_target // args.batch_size +BATCH_COPIES = args.batch_size +temperatures = [float(item) for item in args.sampling_temp.split()] +omit_AAs_list = args.omit_AAs +alphabet = "ACDEFGHIKLMNPQRSTVWYX" +alphabet_dict = dict(zip(alphabet, range(21))) +print_all = args.suppress_print == 0 +omit_AAs_np = np.array([AA in omit_AAs_list for AA in alphabet]).astype(np.float32) +if os.path.isfile(args.chain_id_jsonl): + with open(args.chain_id_jsonl, "r", encoding="utf-8") as json_file: + json_list = list(json_file) + for json_str in json_list: + chain_id_dict = json.loads(json_str) +else: + chain_id_dict = None + if print_all: + print(40 * "-") + print("chain_id_jsonl is NOT loaded") + +if os.path.isfile(args.fixed_positions_jsonl): + with open(args.fixed_positions_jsonl, "r", encoding="utf-8") as json_file: + json_list = list(json_file) + for json_str in json_list: + fixed_positions_dict = json.loads(json_str) +else: + if print_all: + print(40 * "-") + print("fixed_positions_jsonl is NOT loaded") + fixed_positions_dict = None + +if os.path.isfile(args.pssm_jsonl): + with open(args.pssm_jsonl, "r", encoding='utf-8') as json_file: + json_list = list(json_file) + pssm_dict = {} + for json_str in json_list: + pssm_dict.update(json.loads(json_str)) +else: + if print_all: + print(40 * "-") + print("pssm_jsonl is NOT loaded") + pssm_dict = None + +if os.path.isfile(args.omit_AA_jsonl): + with open(args.omit_AA_jsonl, "r", encoding='utf-8') as json_file: + json_list = list(json_file) + for json_str in json_list: + omit_AA_dict = json.loads(json_str) +else: + if print_all: + print(40 * "-") + print("omit_AA_jsonl is NOT loaded") + omit_AA_dict = None + +if os.path.isfile(args.bias_AA_jsonl): + with open(args.bias_AA_jsonl, "r", encoding='utf-8') as json_file: + json_list = list(json_file) + for json_str in json_list: + bias_AA_dict = json.loads(json_str) +else: + if print_all: + print(40 * "-") + print("bias_AA_jsonl is NOT loaded") + bias_AA_dict = None + +if os.path.isfile(args.tied_positions_jsonl): + with open(args.tied_positions_jsonl, "r", encoding='utf-8') as json_file: + json_list = list(json_file) + for json_str in json_list: + tied_positions_dict = json.loads(json_str) +else: + if print_all: + print(40 * "-") + print("tied_positions_jsonl is NOT loaded") + tied_positions_dict = None + +if os.path.isfile(args.bias_by_res_jsonl): + with open(args.bias_by_res_jsonl, "r", encoding='utf-8') as json_file: + json_list = list(json_file) + + for json_str in json_list: + bias_by_res_dict = json.loads(json_str) + if print_all: + print("bias by residue dictionary is loaded") +else: + if print_all: + print(40 * "-") + print("bias by residue dictionary is not loaded, or not provided") + bias_by_res_dict = None + +if print_all: + print(40 * "-") +bias_AAs_np = np.zeros(len(alphabet)) +if bias_AA_dict: + for n, AA in enumerate(alphabet): + if AA in list(bias_AA_dict.keys()): + bias_AAs_np[n] = bias_AA_dict[AA] + +if args.pdb_path: + pdb_dict_list = parse_PDB(args.pdb_path, ca_only=args.ca_only) + dataset_valid = StructureDatasetPDB( + pdb_dict_list, truncate=None, max_length=args.max_length + ) + all_chain_list = [ + item[-1:] for item in list(pdb_dict_list[0]) if item[:9] == "seq_chain" + ] # ['A','B', 'C',...] + if args.pdb_path_chains: + designed_chain_list = [str(item) for item in args.pdb_path_chains.split()] + else: + designed_chain_list = all_chain_list + fixed_chain_list = [ + letter for letter in all_chain_list if letter not in designed_chain_list + ] + chain_id_dict = {} + chain_id_dict[pdb_dict_list[0]["name"]] = ( + designed_chain_list, + fixed_chain_list, + ) +else: + dataset_valid = StructureDataset( + args.jsonl_path, + truncate=None, + max_length=args.max_length, + verbose=print_all, + ) + +with open(checkpoint_path, "rb") as f: + checkpoint = pickle.load(f) +noise_level_print = checkpoint["noise_level"] +model = ProteinMPNN( + ca_only=args.ca_only, + num_letters=21, + node_features=hidden_dim, + edge_features=hidden_dim, + hidden_dim=hidden_dim, + num_encoder_layers=num_layers, + num_decoder_layers=num_layers, + augment_eps=args.backbone_noise, + k_neighbors=checkpoint["num_edges"], +) +model.load_state_dict(checkpoint["model_state_dict"]) +model.set_train(False) + +if print_all: + print(40 * "-") + print("Number of edges:", checkpoint["num_edges"]) + print(f"Training noise level: {noise_level_print}A") + +# Build paths for experiment +base_folder = ensure_output_dirs( + folder_for_outputs, + save_score=bool(args.save_score), + score_only=bool(args.score_only), + conditional_probs_only=bool(args.conditional_probs_only), + unconditional_probs_only=bool(args.unconditional_probs_only), + save_probs=bool(args.save_probs), +) + +# Validation epoch +for ix, protein in enumerate(dataset_valid): + score_list = [] + global_score_list = [] + all_probs_list = [] + all_log_probs_list = [] + S_sample_list = [] + batch_clones = [copy.deepcopy(protein) for i in range(BATCH_COPIES)] + ( + X, + S, + mask, + lengths, + chain_M, + chain_encoding_all, + chain_list_list, + visible_list_list, + masked_list_list, + masked_chain_length_list_list, + chain_M_pos, + omit_AA_mask, + residue_idx, + dihedral_mask, + tied_pos_list_of_lists_list, + pssm_coef, + pssm_bias, + pssm_log_odds_all, + bias_by_res_all, + tied_beta, + ) = tied_featurize( + batch_clones, + chain_id_dict, + fixed_positions_dict, + omit_AA_dict, + tied_positions_dict, + pssm_dict, + bias_by_res_dict, + ca_only=args.ca_only, + ) + pssm_log_odds_mask = ( + pssm_log_odds_all > args.pssm_threshold + ).float() # 1.0 for true, 0.0 for false + name_ = batch_clones[0]["name"] + if args.score_only: + loop_c = 0 + if args.path_to_fasta: + fasta_names, fasta_seqs = parse_fasta(args.path_to_fasta, omit=["/"]) + loop_c = len(fasta_seqs) + for fc in range(1 + loop_c): + if fc == 0: + structure_sequence_score_file = ( + base_folder + "/score_only/" + batch_clones[0]["name"] + "_pdb" + ) + else: + structure_sequence_score_file = ( + base_folder + + "/score_only/" + + batch_clones[0]["name"] + + f"_fasta_{fc}" + ) + native_score_list = [] + global_native_score_list = [] + if fc > 0: + input_seq_length = len(fasta_seqs[fc - 1]) + S_input = ms.tensor( + [alphabet_dict[AA] for AA in fasta_seqs[fc - 1]] + )[None, :].repeat(X.shape[0], 1) + S[:, :input_seq_length] = ( + S_input # assumes that S and S_input are alphabetically sorted for masked_chains + ) + for j in range(NUM_BATCHES): + randn_1 = ms.mint.randn(chain_M.shape) + native_score, global_native_score, _, _ = forward_scores( + model, X, S, mask, chain_M, chain_M_pos, residue_idx, chain_encoding_all, randn_1 + ) + native_score_list.append(native_score) + global_native_score_list.append(global_native_score) + native_score = np.concatenate(native_score_list, 0) + global_native_score = np.concatenate(global_native_score_list, 0) + ns_mean_print = format4(native_score.mean()) + ns_std_print = format4(native_score.std()) + global_ns_mean_print = format4(global_native_score.mean()) + global_ns_std_print = format4(global_native_score.std()) + + ns_sample_size = native_score.shape[0] + seq_str = _S_to_seq(S[0,], chain_M[0,]) + np.savez( + structure_sequence_score_file, + score=native_score, + global_score=global_native_score, + S=S[0,].numpy(), + seq_str=seq_str, + ) + if print_all: + if fc == 0: + print( + f"Score for {name_} from PDB, mean: {ns_mean_print}, std: {ns_std_print}, " + + f"sample size: {ns_sample_size}, global score, mean: {global_ns_mean_print}, " + + f"std: {global_ns_std_print}, sample size: {ns_sample_size}" + ) + else: + print( + f"Score for {name_}_{fc} from FASTA, mean: {ns_mean_print}, std: {ns_std_print}, " + + f"sample size: {ns_sample_size}, global score, mean: {global_ns_mean_print}, " + + f"std: {global_ns_std_print}, sample size: {ns_sample_size}" + ) + elif args.conditional_probs_only: + if print_all: + print(f"Calculating conditional probabilities for {name_}") + conditional_probs_only_file = ( + base_folder + "/conditional_probs_only/" + batch_clones[0]["name"] + ) + log_conditional_probs_list = [] + for j in range(NUM_BATCHES): + randn_1 = ms.mint.randn(chain_M.shape) + log_conditional_probs = model.conditional_probs( + X, + S, + mask, + chain_M * chain_M_pos, + residue_idx, + chain_encoding_all, + randn_1, + args.conditional_probs_only_backbone, + ) + log_conditional_probs_list.append(log_conditional_probs.numpy()) + concat_log_p = np.concatenate(log_conditional_probs_list, 0) # [B, L, 21] + mask_out = (chain_M * chain_M_pos * mask)[0,].numpy() + np.savez( + conditional_probs_only_file, + log_p=concat_log_p, + S=S[0,].numpy(), + mask=mask[0,].numpy(), + design_mask=mask_out, + ) + elif args.unconditional_probs_only: + if print_all: + print(f"Calculating sequence unconditional probabilities for {name_}") + unconditional_probs_only_file = ( + base_folder + "/unconditional_probs_only/" + batch_clones[0]["name"] + ) + log_unconditional_probs_list = [] + for j in range(NUM_BATCHES): + log_unconditional_probs = model.unconditional_probs( + X, mask, residue_idx, chain_encoding_all + ) + log_unconditional_probs_list.append(log_unconditional_probs.numpy()) + concat_log_p = np.concatenate(log_unconditional_probs_list, 0) # [B, L, 21] + mask_out = (chain_M * chain_M_pos * mask)[0,].numpy() + np.savez( + unconditional_probs_only_file, + log_p=concat_log_p, + S=S[0,].numpy(), + mask=mask[0,].numpy(), + design_mask=mask_out, + ) + else: + randn_1 = ms.mint.randn(chain_M.shape) + native_score, global_native_score, _, mask_for_loss = forward_scores( + model, X, S, mask, chain_M, chain_M_pos, residue_idx, chain_encoding_all, randn_1 + ) + # Generate some sequences + ali_file = base_folder + "/seqs/" + batch_clones[0]["name"] + ".fa" + score_file = base_folder + "/scores/" + batch_clones[0]["name"] + ".npz" + probs_file = base_folder + "/probs/" + batch_clones[0]["name"] + ".npz" + if print_all: + print(f"Generating sequences for: {name_}") + t0 = time.time() + with open(ali_file, "w", encoding='utf-8') as f: + for temp in temperatures: + for j in range(NUM_BATCHES): + randn_2 = ms.mint.randn(chain_M.shape) + if tied_positions_dict is None: + sample_dict = model.sample( + X, + randn_2, + S, + chain_M, + chain_encoding_all, + residue_idx, + mask=mask, + temperature=temp, + omit_AAs_np=omit_AAs_np, + bias_AAs_np=bias_AAs_np, + chain_M_pos=chain_M_pos, + omit_AA_mask=omit_AA_mask, + pssm_coef=pssm_coef, + pssm_bias=pssm_bias, + pssm_multi=args.pssm_multi, + pssm_log_odds_flag=bool(args.pssm_log_odds_flag), + pssm_log_odds_mask=pssm_log_odds_mask, + pssm_bias_flag=bool(args.pssm_bias_flag), + bias_by_res=bias_by_res_all, + ) + S_sample = sample_dict["S"] + else: + sample_dict = model.tied_sample( + X, + randn_2, + S, + chain_M, + chain_encoding_all, + residue_idx, + mask=mask, + temperature=temp, + omit_AAs_np=omit_AAs_np, + bias_AAs_np=bias_AAs_np, + chain_M_pos=chain_M_pos, + omit_AA_mask=omit_AA_mask, + pssm_coef=pssm_coef, + pssm_bias=pssm_bias, + pssm_multi=args.pssm_multi, + pssm_log_odds_flag=bool(args.pssm_log_odds_flag), + pssm_log_odds_mask=pssm_log_odds_mask, + pssm_bias_flag=bool(args.pssm_bias_flag), + tied_pos=tied_pos_list_of_lists_list[0], + tied_beta=tied_beta, + bias_by_res=bias_by_res_all, + ) + # Compute scores + S_sample = sample_dict["S"] + scores, global_scores, log_probs, mask_for_loss = forward_scores( + model, X, S_sample, mask, chain_M, chain_M_pos, residue_idx, chain_encoding_all, randn_2, + use_input_decoding_order=True, decoding_order=sample_dict["decoding_order"] + ) + + all_probs_list.append(sample_dict["probs"].data.numpy()) + all_log_probs_list.append(log_probs.data.numpy()) + S_sample_list.append(S_sample.data.numpy()) + for b_ix in range(BATCH_COPIES): + masked_chain_length_list = masked_chain_length_list_list[ + b_ix + ] + masked_list = masked_list_list[b_ix] + seq_recovery_rate = ms.mint.sum( + ms.mint.sum( + ms.mint.nn.functional.one_hot(S[b_ix], 21) + * ms.mint.nn.functional.one_hot(S_sample[b_ix], 21), + dim=-1, + ) + * mask_for_loss[b_ix] + ) / ms.mint.sum(mask_for_loss[b_ix]) + seq = _S_to_seq(S_sample[b_ix], chain_M[b_ix]) + score = scores[b_ix] + score_list.append(score) + global_score = global_scores[b_ix] + global_score_list.append(global_score) + native_seq = _S_to_seq(S[b_ix], chain_M[b_ix]) + if b_ix == 0 and j == 0 and temp == temperatures[0]: + native_seq = seq_with_slashes(native_seq, masked_chain_length_list, masked_list) + sorted_masked_chain_letters = np.argsort( + masked_list_list[0] + ) + print_masked_chains = [ + masked_list_list[0][i] + for i in sorted_masked_chain_letters + ] + sorted_visible_chain_letters = np.argsort( + visible_list_list[0] + ) + print_visible_chains = [ + visible_list_list[0][i] + for i in sorted_visible_chain_letters + ] + native_score_print = format4(native_score.mean()) + global_native_score_print = format4(global_native_score.mean()) + script_dir = os.path.dirname(os.path.realpath(__file__)) + if args.ca_only: + print_model_name = "CA_model_name" + else: + print_model_name = "model_name" + f.write( + f">{name_}, score={native_score_print}, global_score={global_native_score_print}, " + f"fixed_chains={print_visible_chains}, designed_chains={print_masked_chains}, " + f"{print_model_name}={args.model_name}, seed={seed}\n{native_seq}\n" + ) # write the native sequence + seq = seq_with_slashes(seq, masked_chain_length_list, masked_list) + score_print = format4(score) + global_score_print = format4(global_score) + seq_rec_print = format4(seq_recovery_rate.numpy()) + sample_number = j * BATCH_COPIES + b_ix + 1 + f.write( + f">T={temp}, sample={sample_number}, score={score_print}, " + + f"global_score={global_score_print}, seq_recovery={seq_rec_print}\n{seq}\n" + ) # write generated sequence + if args.save_score: + np.savez( + score_file, + score=np.array(score_list, np.float32), + global_score=np.array(global_score_list, np.float32), + ) + if args.save_probs: + all_probs_concat = np.concatenate(all_probs_list) + all_log_probs_concat = np.concatenate(all_log_probs_list) + S_sample_concat = np.concatenate(S_sample_list) + np.savez( + probs_file, + probs=np.array(all_probs_concat, np.float32), + log_probs=np.array(all_log_probs_concat, np.float32), + S=np.array(S_sample_concat, np.int32), + mask=mask_for_loss.data.numpy(), + chain_order=chain_list_list, + ) + t1 = time.time() + dt = round(float(t1 - t0), 4) + num_seqs = len(temperatures) * NUM_BATCHES * BATCH_COPIES + total_length = X.shape[1] + if print_all: + print( + f"{num_seqs} sequences of length {total_length} generated in {dt} seconds" + ) diff --git a/MindSPONGE/applications/proteinmpnn/requirements.txt b/MindSPONGE/applications/proteinmpnn/requirements.txt new file mode 100644 index 000000000..8fbc34979 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/requirements.txt @@ -0,0 +1 @@ +mindspore==2.7.1 \ No newline at end of file diff --git a/MindSPONGE/applications/proteinmpnn/scripts/download_weights.sh b/MindSPONGE/applications/proteinmpnn/scripts/download_weights.sh new file mode 100644 index 000000000..9702dcaf5 --- /dev/null +++ b/MindSPONGE/applications/proteinmpnn/scripts/download_weights.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +DOWNLOAD_DIR="./weights" +EXAMPLE_DIR="./examples" + +if ! command -v wget &> /dev/null +then + echo "Error: wget could not be found. Please install wget (sudo apt-get install wget)" + exit 1 +fi + +mkdir -p "${DOWNLOAD_DIR}" +wget -P "${DOWNLOAD_DIR}/ca_model_weights/" \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/ca_model_weights/v_48_002.ckpt \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/ca_model_weights/v_48_010.ckpt \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/ca_model_weights/v_48_020.ckpt +wget -P "${DOWNLOAD_DIR}/soluble_model_weights/" \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/soluble_model_weights/v_48_002.ckpt \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/soluble_model_weights/v_48_010.ckpt \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/soluble_model_weights/v_48_020.ckpt \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/soluble_model_weights/v_48_030.ckpt +wget -P "${DOWNLOAD_DIR}/vanilla_model_weights/" \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/vanilla_model_weights/v_48_002.ckpt \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/vanilla_model_weights/v_48_010.ckpt \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/vanilla_model_weights/v_48_020.ckpt \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/vanilla_model_weights/v_48_030.ckpt + +wget -P "${EXAMPLE_DIR}" \ + https://tools.mindspore.cn/dataset/workspace/mindspore_ckpt/ckpt/ProteinMPNN/example_inputs.zip \ No newline at end of file -- Gitee