################################################################################
################ AAindex1 #################
################################################################################
#importing required modules and dependencies
import difflib
import json
import math
import os
import sys
import copy
import re
import csv
import warnings
from importlib.metadata import version as _pkg_version, PackageNotFoundError
from typing import Any, Iterator
from ._aaindex_matrix import Map
__all__: list[str] = ['AAIndex1', 'aaindex1']
[docs]
class AAIndex1():
"""Python parser for AAindex1: Amino Acid Index Database.
The AAindex is a database of numerical indices representing various
physicochemical and biochemical properties of amino acids. This class
stores the amino acid index of 20 numerical values for the 20 amino
acids — AAindex1 (http://www.genome.jp/aaindex/).
Attributes:
aaindex_module_path: Absolute path to the aaindex package directory.
data_dir: Subdirectory name containing raw and cached data files.
aaindex_filename: Base filename for this database (no extension).
aaindex_json: Parsed database keyed by accession number.
categories: Dict mapping each record code to its category.
last_updated: Date string of the last published database update.
"""
def __init__(self) -> None:
#resolve the package directory for data file lookups
self.aaindex_module_path = os.path.dirname(os.path.abspath(sys.modules[self.__module__].__file__))
self.data_dir = "data"
self.aaindex_filename = "aaindex1"
#date as shown on https://www.genome.jp/aaindex/
self.last_updated = "February 13, 2017"
#database and categories are loaded lazily on first access
self._aaindex_json: dict | None = None
self._categories: dict | None = None
self._amino_acids_cache: list[str] | None = None
try:
self.version = _pkg_version("aaindex")
except PackageNotFoundError:
self.version = "unknown"
def _load_data(self) -> None:
"""Load the database from the JSON cache, or parse the raw file if the cache is absent."""
json_path = os.path.join(self.aaindex_module_path, self.data_dir, f"{self.aaindex_filename}.json")
if os.path.isfile(json_path):
with open(json_path) as aai_json:
self._aaindex_json = json.load(aai_json)
#normalise types that may differ in older cached JSON
for record in self._aaindex_json.values():
cc = record.get('correlation_coefficients', {})
for k, v in list(cc.items()):
if isinstance(v, str):
try:
cc[k] = float(v)
except ValueError:
pass
vals = record.get('values', {})
if '-' in vals and isinstance(vals['-'], int):
vals['-'] = float(vals['-'])
else:
self._aaindex_json = self._parse_aaindex()
self._amino_acids_cache = sorted(
self._aaindex_json[next(iter(self._aaindex_json))]["values"].keys()
)
def _parse_aaindex(self) -> dict:
"""Parse the raw AAindex1 database file into a nested dict and cache as JSON.
Each record is keyed by its accession number and stores metadata, amino
acid values, and category. The result is written to a .json file in the
data directory for fast subsequent loads.
Returns:
Parsed database keyed by accession number.
Raises:
IOError: If the raw database file cannot be opened.
ValueError: If a duplicate accession number is encountered.
"""
#initialise keys of AAi database
template_dict = {
"H": [], "D": [], "R": [], "A": [],
"*": [], "T": [], "J": [], "C": [], "I": [],
}
#open AAi file for reading and parsing
tmp_filepath = os.path.join(self.aaindex_module_path, self.data_dir, self.aaindex_filename)
try:
with open(tmp_filepath) as f:
lines = f.readlines()
except OSError as e:
raise OSError(f"Error opening AAindex1 file, check file is in filepath: {tmp_filepath}.") from e
#regex to normalise double-quote characters in field values
clean_up_pattern = re.compile("\"")
aaindex_json: dict = {}
current_dict = copy.deepcopy(template_dict)
current_entry: str = "H" # first non-space line in any block is always H
#iterate through each line, parsing records delimited by '//'
for line in lines:
if line.startswith("//"):
#handle meta data of each record
name = clean_up_pattern.sub("'", " ".join(current_dict["H"]))
description = clean_up_pattern.sub("'", " ".join(current_dict["D"]))
#append author, title and journal name to reference
a = " ".join(current_dict["A"])
t = " ".join(current_dict["T"])
j = " ".join(current_dict["J"])
references = clean_up_pattern.sub("'", f"{a} '{t}' {j}")
#parse pub med article ID
pmid = clean_up_pattern.sub("'", " ".join(current_dict["R"]))
pmid = pmid.replace("PMID:", "")
#parse correlation coefficients into a dict (values stored as float)
correlation_coefficient = clean_up_pattern.sub("'", " ".join(current_dict["C"]))
correlation_coefficient_ = {}
correlation_coefficient_list = [
correlation_coefficient.split()[n:n + 2]
for n in range(0, len(correlation_coefficient.split()), 2)
]
for correlation in correlation_coefficient_list:
if len(correlation) == 2:
try:
correlation_coefficient_[correlation[0]] = float(correlation[1])
except ValueError:
pass
#parse notes from record
notes = clean_up_pattern.sub("'", " ".join(current_dict["*"]))
#parse individual amino acid values from I-lines
aa_lines = current_dict["I"]
if len(aa_lines) < 3:
warnings.warn(
f"Record {name!r} has a malformed I-block ({len(aa_lines)} line(s)); skipping.",
UserWarning, stacklevel=2,
)
current_dict = copy.deepcopy(template_dict)
continue
aa_names = aa_lines[0].split()
row_0_names = [aa.split("/")[0] for aa in aa_names]
row_1_names = [aa.split("/")[1] for aa in aa_names]
row_0_values = aa_lines[1].split()
row_1_values = aa_lines[2].split()
values: dict = {}
for i in range(len(row_0_values)):
try:
values[row_0_names[i]] = float(row_0_values[i])
except ValueError:
values[row_0_names[i]] = None
try:
values[row_1_names[i]] = float(row_1_values[i])
except ValueError:
values[row_1_names[i]] = None
#guard against duplicate accession numbers
if name in aaindex_json:
raise ValueError(f"Duplicate AAi Record found: {name}.")
aaindex_json[name] = {
"description": description,
"references": references,
"pmid": pmid,
"correlation_coefficients": correlation_coefficient_,
"notes": notes,
"values": values,
}
current_dict = copy.deepcopy(template_dict)
continue
#B7: guard against unknown field codes to prevent KeyError
if line[0] != " ":
if line[0] not in current_dict:
warnings.warn(
f"Unknown field code {line[0]!r} in AAindex1 file; line ignored.",
UserWarning, stacklevel=2,
)
continue
current_entry = line[0]
current_dict[current_entry].append(line[1:].strip())
#post-process: assign category and '-' gap placeholder (NA stays as None)
for index in aaindex_json:
aaindex_json[index]['category'] = self.categories[index]
aaindex_json[index]['values']['-'] = 0.0
#cache parsed database as JSON for fast subsequent loads; non-fatal if the data dir is read-only
json_out_path = os.path.join(
self.aaindex_module_path, self.data_dir, f"{self.aaindex_filename}.json"
)
try:
with open(json_out_path, 'w') as output_f:
json.dump(aaindex_json, output_f, indent=4, sort_keys=True)
except OSError:
pass
return aaindex_json
[docs]
def parse_aaindex(self) -> dict:
"""Deprecated: use _parse_aaindex() instead.
.. deprecated::
This method is an internal implementation detail and will be
removed in a future version.
"""
warnings.warn(
"parse_aaindex() is deprecated and will be removed in a future version. "
"It is an internal implementation detail.",
DeprecationWarning,
stacklevel=2,
)
return self._parse_aaindex()
def _parse_categories(self, aaindex_category_file: str = 'aaindex_to_category.txt') -> dict:
"""Parse category file mapping each AAi record to one of 8 categories.
Category file and parsing code inspired from https://github.com/harmslab/hops.
Accepts only a bare filename (resolved against the data directory) or an
absolute path. Relative paths containing directory components are rejected
to prevent path traversal.
Args:
aaindex_category_file: Bare filename or absolute path of the category
mapping file. Defaults to ``aaindex_to_category.txt`` in the data
directory.
Returns:
Dict mapping each record code to its category string.
Raises:
ValueError: If *aaindex_category_file* contains path separators and is
not an absolute path.
IOError: If the category file cannot be opened.
"""
#S1 fix: reject relative paths with directory components to prevent traversal
if os.path.isabs(aaindex_category_file):
category_filepath = aaindex_category_file
elif os.path.basename(aaindex_category_file) != aaindex_category_file:
raise ValueError(
f"aaindex_category_file must be a bare filename or an absolute path; "
f"relative paths with directory components are not allowed, "
f"got: {aaindex_category_file!r}"
)
else:
category_filepath = os.path.join(self.aaindex_module_path, self.data_dir, aaindex_category_file)
try:
with open(category_filepath) as f:
category_lines = f.readlines()
except OSError as e:
raise OSError(f"Error opening AAindex1 category file: {category_filepath}.") from e
#B7 fix: filter comment lines in memory — no intermediate file write needed
aaindex_category: dict = {}
reader = csv.reader(
(line for line in category_lines if not line.startswith('#')),
delimiter="\t",
)
for row in reader:
if len(row) >= 2:
category_substring = row[1].strip().split(" ", 1)
aaindex_category[row[0]] = category_substring[0]
return aaindex_category
[docs]
def parse_categories(self, aaindex_category_file: str = 'aaindex_to_category.txt') -> dict:
"""Deprecated: use _parse_categories() instead.
.. deprecated::
This method is an internal implementation detail and will be
removed in a future version.
"""
warnings.warn(
"parse_categories() is deprecated and will be removed in a future version. "
"It is an internal implementation detail.",
DeprecationWarning,
stacklevel=2,
)
return self._parse_categories(aaindex_category_file)
[docs]
def get_all_categories(self, category_file: str = "aaindex_to_category.txt") -> dict:
"""Return dict mapping every record code to its category.
Delegates to :meth:`_parse_categories`.
Args:
category_file: Bare filename or absolute path of the category mapping
file. Defaults to ``aaindex_to_category.txt`` in the data directory.
Returns:
Dict mapping each record code to its category string.
"""
return self._parse_categories(category_file)
[docs]
def search(self, query: str | list[str]) -> dict:
"""Search records by keyword(s) across all text fields.
Searches the description, accession code, PMID, references, and notes
fields of each record. Matching is case-insensitive. Results are returned
sorted by accession number.
Args:
query: Keyword string or list of keyword strings.
Returns:
Dict of matching records keyed by accession number, sorted
alphabetically. Returns an empty dict if no records match.
Raises:
TypeError: If query is not a str or list.
"""
all_indices: dict = {}
if not isinstance(query, (list, str)):
raise TypeError(f"Input query parameter must be a list or str, got {type(query)}.")
#convert query parameter to list to make iterable
if not isinstance(query, list):
query = [query]
#iterate over query list, matching keywords case-insensitively across all text fields
for term in query:
term_lower = term.lower()
for index, record in self.aaindex_json.items():
if (
term_lower in record['description'].lower()
or term_lower in index.lower()
or term_lower in record['pmid'].lower()
or term_lower in record['references'].lower()
or term_lower in record['notes'].lower()
):
all_indices[index] = record
#I6: return results sorted by accession code for deterministic ordering
return dict(sorted(all_indices.items()))
def search_fuzzy(
self,
query: str,
n: int = 10,
cutoff: float = 0.0,
) -> dict:
"""Search records using fuzzy matching with ranked results.
Scores each record by how closely *query* matches any of its text
fields (description, accession code, PMID, references, notes, category)
using ``difflib.SequenceMatcher``. Results are returned in descending
score order (most relevant first) with no external dependencies.
Args:
query: Search string.
n: Maximum number of results to return. Defaults to ``10``.
cutoff: Minimum similarity score in [0, 1] to include a record.
Defaults to ``0.0`` (all records scored, top *n* returned).
Returns:
Dict of matching records keyed by accession number, ordered by
descending similarity score.
Raises:
TypeError: If *query* is not a str.
ValueError: If *n* < 1 or *cutoff* is outside [0, 1].
"""
if not isinstance(query, str):
raise TypeError(f"query must be a str, got {type(query)}.")
if not isinstance(n, int) or n < 1:
raise ValueError(f"n must be a positive int, got {n}.")
if not (0.0 <= cutoff <= 1.0):
raise ValueError(f"cutoff must be in [0, 1], got {cutoff}.")
query_lower = query.lower()
scored: list[tuple[float, str]] = []
for code, record in self.aaindex_json.items():
candidate_fields = [
record.get("description", ""),
code,
record.get("pmid", ""),
record.get("references", ""),
record.get("notes", ""),
record.get("category", ""),
]
score = max(
difflib.SequenceMatcher(None, query_lower, field.lower()).ratio()
for field in candidate_fields
if field
)
if score >= cutoff:
scored.append((score, code))
scored.sort(key=lambda x: x[0], reverse=True)
return {code: self.aaindex_json[code] for _, code in scored[:n]}
[docs]
def amino_acids(self) -> list[str]:
"""Return sorted list of amino acid single-letter codes.
Includes the ``-`` placeholder for absent/gap amino acids.
Returns:
Sorted list of amino acid codes including ``-``.
"""
#trigger lazy load if necessary (also sets _amino_acids_cache)
_ = self.aaindex_json
return self._amino_acids_cache
[docs]
def record_codes(self) -> list[str]:
"""Return sorted list of all accession numbers in the database.
Returns:
Sorted list of accession number strings.
"""
records = list(self.aaindex_json.keys())
records.sort()
return records
[docs]
def num_records(self) -> int:
"""Return the total number of records in the database.
Returns:
Number of records as int.
"""
return len(self.aaindex_json)
[docs]
def record_names(self) -> list[str]:
"""Return a list of description strings for all records.
Returns:
List of description strings in database insertion order.
"""
return [v['description'] for v in self.aaindex_json.values()]
[docs]
def values(self, record_code: str) -> dict:
"""Return the amino acid values dict for a given record.
Shortcut to avoid accessing the full record when only
the values are needed.
Args:
record_code: AAindex accession number.
Returns:
Dict of amino acid values for the specified record.
Raises:
ValueError: If record_code is not found in the database.
"""
return self[record_code]['values']
def valid_categories(self) -> list[str]:
"""Return a sorted list of all distinct category strings in the database.
Returns:
Sorted list of valid category name strings.
"""
return sorted({record['category'] for record in self.aaindex_json.values()})
[docs]
def get_record_by_category(self, category: str) -> dict:
"""Return all records belonging to a given category.
Args:
category: Category name to filter records by (case-insensitive).
Returns:
Dict of matching records keyed by accession number.
Raises:
TypeError: If category is not a string.
"""
if not isinstance(category, str):
raise TypeError(f"Input category parameter must be a str, got {type(category)}.")
#validate the category against known values; raise with a helpful hint if unknown
valid = self.valid_categories()
valid_lower = [c.lower() for c in valid]
if category.lower() not in valid_lower:
close = difflib.get_close_matches(category.lower(), valid_lower, n=3, cutoff=0.6)
if close:
close_actual = [valid[valid_lower.index(c)] for c in close]
hint = f" Did you mean: {close_actual}?"
else:
hint = f" Valid categories are: {valid}."
raise ValueError(f"Category '{category}' not found in AAindex1.{hint}")
#filter records by matching category field
category_records = {
code: record for code, record in self.aaindex_json.items()
if record.get('category', '').lower() == category.lower()
}
return category_records
def encode(
self,
sequence: str,
record_code: str,
gap_value: float = 0.0,
unknown_value: float | None = None,
) -> list[float | None]:
"""Encode a protein sequence as a numeric feature vector using one index.
Each amino acid in *sequence* is replaced by its corresponding value
from the named AAindex1 record. Gaps (``-``) are replaced by
*gap_value*. Amino acids whose value is NA in the source data are
represented as ``None``. Amino acids not in the 20 canonical set are
represented as *unknown_value* (default ``None``).
Args:
sequence: Protein sequence string of single-letter amino acid codes.
Case-insensitive.
record_code: AAindex1 accession number.
gap_value: Numeric value substituted for gap characters (``-``).
Defaults to ``0.0``.
unknown_value: Value substituted for non-canonical amino acid
characters (e.g. ``B``, ``X``, ``Z``). Defaults to
``None``. Set to a float (e.g. ``0.0``) to fill
unknowns with a fixed value instead.
Returns:
List of floats (or ``None``) with the same length as *sequence*.
Raises:
TypeError: If *sequence* is not a string.
ValueError: If *record_code* is not found in the database.
Example:
>>> aaindex1.encode('ACDE', 'KYTJ820101')
[1.8, 2.5, -3.5, -3.5]
"""
if not isinstance(sequence, str):
raise TypeError(f"sequence must be a str, got {type(sequence)}.")
values = self[record_code]['values']
result: list[float | None] = []
for aa in sequence.upper():
if aa == '-':
result.append(gap_value)
elif aa in values:
result.append(values[aa]) # None if NA value in source data
else:
result.append(unknown_value) # non-canonical amino acid
return result
def batch_encode(
self,
sequences: list[str],
record_code: str,
gap_value: float = 0.0,
unknown_value: float | None = None,
) -> list[list[float | None]]:
"""Encode multiple protein sequences as numeric feature vectors using one index.
Convenience wrapper around :meth:`encode` for processing a collection of
sequences with a single AAindex1 record in one call. The record is looked
up once and reused for all sequences, making this more efficient than
calling :meth:`encode` in a loop.
Args:
sequences: List of protein sequence strings of single-letter amino
acid codes. Case-insensitive.
record_code: AAindex1 accession number.
gap_value: Numeric value substituted for gap characters (``-``).
Defaults to ``0.0``.
unknown_value: Value substituted for non-canonical amino acid
characters. Defaults to ``None``.
Returns:
List of encoded sequences; each inner list has the same length as the
corresponding input sequence and contains floats or ``None``.
Raises:
TypeError: If *sequences* is not a list, or any element is not a str.
ValueError: If *record_code* is not found in the database.
Example:
>>> aaindex1.batch_encode(['ACDE', 'FGHI'], 'KYTJ820101')
[[1.8, 2.5, -3.5, -3.5], [-3.2, -0.4, -3.2, -3.2]]
"""
if not isinstance(sequences, list):
raise TypeError(f"sequences must be a list, got {type(sequences)}.")
values = self[record_code]['values']
result: list[list[float | None]] = []
for i, seq in enumerate(sequences):
if not isinstance(seq, str):
raise TypeError(f"sequences[{i}] must be a str, got {type(seq)}.")
encoded: list[float | None] = []
for aa in seq.upper():
if aa == '-':
encoded.append(gap_value)
elif aa in values:
encoded.append(values[aa])
else:
encoded.append(unknown_value)
result.append(encoded)
return result
def multi_encode(
self,
sequence: str,
record_codes: list[str],
gap_value: float = 0.0,
unknown_value: float | None = None,
) -> dict[str, list[float | None]]:
"""Encode a protein sequence against multiple AAindex1 records at once.
Encodes *sequence* using each record in *record_codes* and returns a
dict mapping each accession number to its encoded vector. Each record is
validated before encoding begins, so an invalid code raises immediately.
A common use case is ML feature extraction, where a sequence must be
represented as a matrix of physicochemical descriptors.
Args:
sequence: Protein sequence string of single-letter amino acid codes.
Case-insensitive.
record_codes: List of AAindex1 accession numbers.
gap_value: Numeric value substituted for gap characters (``-``).
Defaults to ``0.0``.
unknown_value: Value substituted for non-canonical amino acid
characters. Defaults to ``None``.
Returns:
Dict mapping each accession number to its encoded vector (a list of
floats or ``None`` with the same length as *sequence*).
Raises:
TypeError: If *sequence* is not a str or *record_codes* is not a list.
ValueError: If any accession number in *record_codes* is not found.
Example:
>>> aaindex1.multi_encode('ACDE', ['KYTJ820101', 'EISD840101'])
{'KYTJ820101': [1.8, 2.5, -3.5, -3.5], 'EISD840101': [...]}
"""
if not isinstance(sequence, str):
raise TypeError(f"sequence must be a str, got {type(sequence)}.")
if not isinstance(record_codes, list):
raise TypeError(f"record_codes must be a list, got {type(record_codes)}.")
return {
code: self.encode(sequence, code, gap_value, unknown_value)
for code in record_codes
}
def get_correlated_indices(
self,
record_code: str,
min_correlation: float = 0.8,
depth: int = 1,
) -> dict[str, float]:
"""Return a dict of indices correlated with *record_code*.
Traverses the ``correlation_coefficients`` links stored on each record
(pre-computed by the AAindex authors for |r| ≥ 0.8 pairs) up to
*depth* hops from the seed record. Only indices whose stored |r| meets
*min_correlation* are included.
Args:
record_code: Seed AAindex1 accession number.
min_correlation: Minimum absolute correlation coefficient to
include a neighbour. Must be in [0, 1].
Defaults to ``0.8``.
depth: Number of hops to traverse from the seed. ``1`` returns
only direct neighbours; ``2`` also includes their
neighbours, etc. Defaults to ``1``.
Returns:
Dict mapping each correlated accession number to its stored
correlation coefficient (float). The seed record itself is
excluded from the result.
Raises:
TypeError: If *min_correlation* or *depth* are of the wrong type.
ValueError: If *record_code* is not found, or if *min_correlation*
is outside [0, 1], or if *depth* is less than 1.
Example:
>>> aaindex1.get_correlated_indices('KYTJ820101', depth=1)
{'EISD840101': 0.949, ...}
"""
if not isinstance(min_correlation, (int, float)):
raise TypeError(f"min_correlation must be a float, got {type(min_correlation)}.")
if not isinstance(depth, int):
raise TypeError(f"depth must be an int, got {type(depth)}.")
if not (0.0 <= min_correlation <= 1.0):
raise ValueError(f"min_correlation must be in [0, 1], got {min_correlation}.")
if depth < 1:
raise ValueError(f"depth must be >= 1, got {depth}.")
#validate seed — __getitem__ raises ValueError if not found
self[record_code]
visited: dict[str, float] = {}
frontier = {record_code.strip().upper()}
for _ in range(depth):
next_frontier: set = set()
for code in frontier:
if code not in self.aaindex_json:
continue
for neighbour, coeff_str in self.aaindex_json[code]['correlation_coefficients'].items():
try:
coeff = float(coeff_str)
except (ValueError, TypeError):
continue
if abs(coeff) >= min_correlation and neighbour not in visited:
visited[neighbour] = coeff
next_frontier.add(neighbour)
frontier = next_frontier - {record_code.strip().upper()}
#exclude the seed from output
seed_upper = record_code.strip().upper()
visited.pop(seed_upper, None)
return visited
def compare_indices(
self,
code1: str,
code2: str,
) -> float | None:
"""Compute the Pearson correlation coefficient between two AAindex1 records.
Uses only the amino acids that have a non-``None`` value in *both*
records (i.e. NA positions are excluded pairwise). Returns ``None``
when fewer than two valid positions remain after masking.
Args:
code1: First AAindex1 accession number.
code2: Second AAindex1 accession number.
Returns:
Pearson r as a float in [-1, 1], or ``None`` if there are
insufficient non-NA values to compute a correlation.
Raises:
ValueError: If either accession number is not found in the database.
Example:
>>> aaindex1.compare_indices('KYTJ820101', 'EISD840101')
0.949...
"""
vals1 = self[code1]['values']
vals2 = self[code2]['values']
#collect paired values — both must be non-None and not the gap placeholder
pairs = [
(vals1[aa], vals2[aa])
for aa in vals1
if aa != '-' and vals1[aa] is not None and vals2.get(aa) is not None
]
n = len(pairs)
if n < 2:
return None
xs = [p[0] for p in pairs]
ys = [p[1] for p in pairs]
mean_x = sum(xs) / n
mean_y = sum(ys) / n
cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
std_x = math.sqrt(sum((x - mean_x) ** 2 for x in xs))
std_y = math.sqrt(sum((y - mean_y) ** 2 for y in ys))
if std_x == 0.0 or std_y == 0.0:
return None
return cov / (std_x * std_y)
def to_dict(self, record_code: str | None = None) -> dict:
"""Export one record or the full database as a plain Python dict.
Args:
record_code: If given, export only that record keyed by its
accession number. If ``None`` (default), export
the entire database.
Returns:
Dict containing the requested records.
Raises:
ValueError: If *record_code* is not found in the database.
"""
if record_code is not None:
code = record_code.strip().upper()
if code not in self.aaindex_json:
raise ValueError(f"Record ({code}) not found in AAindex1.")
return {code: dict(self.aaindex_json[code])}
return dict(self.aaindex_json)
def to_json(self, record_code: str | None = None, indent: int = 4) -> str:
"""Serialise one record or the full database to a JSON string.
Args:
record_code: If given, serialise only that record. If ``None``
(default), serialise the entire database.
indent: JSON indentation level. Defaults to ``4``.
Returns:
JSON-formatted string.
Raises:
ValueError: If *record_code* is not found in the database.
"""
return json.dumps(self.to_dict(record_code), indent=indent)
def to_dataframe(
self,
record_code: str | None = None,
include_gap: bool = True,
) -> Any:
"""Export amino acid values as a pandas DataFrame.
Requires ``pandas`` to be installed. If pandas is not available a
clear ``ImportError`` is raised rather than a silent failure.
When *record_code* is ``None`` the returned DataFrame has one column
per amino acid and one row per AAindex1 record (index = accession
numbers). When a specific record code is given the DataFrame has a
single row.
Args:
record_code: If given, export only that record's values.
If ``None`` (default), export all records.
include_gap: If ``True`` (default), include the ``-`` gap-placeholder
column. Set to ``False`` to drop it, which is useful for
ML pipelines that expect exactly 20 amino acid columns.
Returns:
``pandas.DataFrame`` with amino acids as columns and record
accession numbers as the index.
Raises:
ImportError: If ``pandas`` is not installed.
ValueError: If *record_code* is not found in the database.
"""
try:
import pandas as pd # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"pandas is required for to_dataframe(). "
"Install it with: pip install pandas"
) from exc
if record_code is not None:
code = record_code.strip().upper()
records_subset = {code: self[code]['values']}
else:
records_subset = {
code: record['values'] for code, record in self.aaindex_json.items()
}
df = pd.DataFrame.from_dict(records_subset, orient='index')
if not include_gap:
df = df.drop(columns=['-'], errors='ignore')
return df
def plot_distribution(self, record_code: str) -> Any:
"""Plot the amino acid value distribution for a record as a bar chart.
Requires ``matplotlib`` to be installed. Amino acids with NA values
(``None``) are excluded from the plot. The gap placeholder ``-`` is
always excluded.
Args:
record_code: AAindex1 accession number.
Returns:
``matplotlib.axes.Axes`` containing the bar chart. The figure can
be displayed with ``plt.show()`` or saved with ``plt.savefig()``.
Raises:
ImportError: If ``matplotlib`` is not installed.
ValueError: If *record_code* is not found in the database.
Example:
>>> ax = aaindex1.plot_distribution('KYTJ820101')
>>> import matplotlib.pyplot as plt; plt.show()
"""
try:
import matplotlib.pyplot as plt # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"matplotlib is required for plot_distribution(). "
"Install it with: pip install matplotlib"
) from exc
record = self[record_code]
vals = {
aa: v for aa, v in record['values'].items()
if aa != '-' and v is not None
}
labels = sorted(vals)
heights = [vals[aa] for aa in labels]
_, ax = plt.subplots()
ax.bar(labels, heights)
ax.set_xlabel("Amino Acid")
ax.set_ylabel("Value")
ax.set_title(record['description'][:80])
return ax
[docs]
def __getitem__(self, record_code: str) -> "Map":
"""Return a record by accession number wrapped in a Map (dot-notation dict).
Args:
record_code: AAindex accession number (case-insensitive,
leading/trailing whitespace is stripped).
Returns:
Record data as a Map, accessible via dict or dot notation.
Raises:
TypeError: If record_code is not a string.
ValueError: If record_code is not found in the database.
"""
try:
record_code = record_code.strip().upper()
except AttributeError:
raise TypeError(
f"Input parameter {record_code} is not of correct datatype string, got {type(record_code)}."
)
if record_code not in self.aaindex_json:
close = difflib.get_close_matches(
record_code, self.aaindex_json.keys(), n=3, cutoff=0.6
)
hint = f" Did you mean: {close}?" if close else ""
raise ValueError(f"Record Index ({record_code}) not found in AAindex1.{hint}")
return Map(self.aaindex_json[record_code])
[docs]
def __sizeof__(self) -> int:
"""Return the on-disk size of the raw AAindex data file in bytes."""
return os.path.getsize(
os.path.join(self.aaindex_module_path, self.data_dir, self.aaindex_filename)
)
[docs]
def __len__(self) -> int:
"""Return total number of records in the database."""
return len(self.aaindex_json)
[docs]
def __contains__(self, record_code: object) -> bool:
"""Return True if record_code exists in the database."""
return record_code in self.aaindex_json
[docs]
def __iter__(self) -> Iterator[str]:
"""Iterate over all record codes in the database."""
return iter(self.aaindex_json)
[docs]
def __repr__(self) -> str:
"""Return a canonical string representation of this instance."""
return f"AAIndex1(records={len(self.aaindex_json)}, last_updated='{self.last_updated}')"
###################### Getters & Setters ######################
@property
def aaindex_json(self) -> dict:
"""Parsed database dict, loaded lazily on first access."""
if self._aaindex_json is None:
self._load_data()
return self._aaindex_json
@aaindex_json.setter
def aaindex_json(self, value: dict) -> None:
self._aaindex_json = value
@property
def categories(self) -> dict:
if self._categories is None:
self._categories = self.get_all_categories()
return self._categories
@categories.setter
def categories(self, value: dict) -> None:
self._categories = value
@property
def data_dir(self) -> str:
return self._data_dir
@data_dir.setter
def data_dir(self, value: str) -> None:
if os.path.basename(value) != value:
raise ValueError(
f"data_dir must be a simple directory name with no path separators, got: {value!r}"
)
self._data_dir = value
@property
def aaindex_filename(self) -> str:
return self._aaindex_filename
@aaindex_filename.setter
def aaindex_filename(self, value: str) -> None:
if os.path.basename(value) != value:
raise ValueError(
f"aaindex_filename must be a simple filename with no path separators, got: {value!r}"
)
self._aaindex_filename = value
@property
def last_updated(self) -> str:
return self._last_updated
@last_updated.setter
def last_updated(self, value: str) -> None:
self._last_updated = value
#create instance of AAIndex1 class
aaindex1 = AAIndex1()