Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions .claude/constants_pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# String Constants Pattern - Instruction Set for Coding Agents

## Purpose

This document provides instructions for introducing string constants for access of
- DataFrame (specifically pd.DataFrame) columns and
- dictionary keys
using the `string constants` pattern.

---

## Motivation

**Problem**: Raw string literals for DataFrame column or dictionary keys access are error-prone and hard to maintain.

```python
# BAD: Magic strings scattered throughout codebase: Typo risk, no IDE support, hard to refactor
some_df["column_name"]
some_dict["key_name"]
```

**Solution**: Centralized, immutable string constants with IDE support.

```python
# GOOD: Centralized constants: IDE autocomplete and navigation, compile-time checking, easy refactoring
some_df[Cols.COLUMN_NAME]
some_dict[Keys.KEY_NAME]
```

**Benefits**:
1. **Single source of truth** - Column names defined once in a constants module
2. **Immutability** - Constants cannot be modified at runtime
3. **IDE support** - Autocomplete, "find usages", safe renaming
4. **Runtime validation** - `get_values()` method for validating against allowed values
5. **Discoverability** - Related constants grouped in semantic classes

---

## Pattern Implementation

The `ConstantsClass` metaclass is required to enforce immutability and provide utility methods.
Add it if it does not already exist in your codebase.

```python
class ConstantsClass(type):
"""A metaclass for classes that should only contain string constants."""

def __setattr__(self, name, value):
raise TypeError("Constants class cannot be modified")

def get_values(cls):
"""Get all user-defined string values of the class."""
return [
value
for key, value in cls.__dict__.items()
if not key.startswith("__") and isinstance(value, str)
]
```

---

## How to Apply This Pattern

### Step 1: Identify Magic Strings

Search for raw string DataFrame access patterns:
```python
# Patterns to find and replace:
df["column_name"] # Bracket access with string literal
df.column_name # Attribute access (also problematic)
some_dict["key_name"] # Dict access for column configs
```

### Step 2: Group Related Constants

Create a new class for semantically related columns. Naming convention: `<Domain>Cols` or `<Domain>Keys`.

```python
class UserCols(metaclass=ConstantsClass):
"""String constants for user table columns."""

ID = "id"
NAME = "name"
EMAIL = "email"
```

### Step 3: Add to Constants Module

Place the new class in the project's constants module (e.g., `constants/keys.py`), grouped with related classes.

### Step 4: Replace Magic Strings

```python
# BEFORE
df["id"]
df["name"]

if "name" in df.columns:
...


# AFTER
from constants.keys import UserCols

df[UserCols.ID]
df[UserCols.NAME]

# make sure to also capture occurrences like this:
if UserCols.NAME in df.columns:
...
```

Important note:
The constants have dataframe-type scope, so don't blindly replace all strings across dataframes. Only replace those relevant to the specific dataframe.
There can well be cases where two dataframes share the same column name, but you should only replace the string literal in the context of the specific dataframe.

Example:
```python

# BEFORE
user_df = user_df[
user_df["id"].isin(some_other_df["id"])
]


# AFTER: WRONG - DO NOT DO THIS
user_df = user_df[
user_df[UserCols.ID].isin(some_other_df[UserCols.ID])
]

# AFTER: RIGHT - DO THIS - OPTION 1/2
user_df = user_df[
user_df[UserCols.ID].isin(some_other_df["id"])
]

# AFTER: RIGHT - DO THIS - OPTION 2/2
user_df = user_df[
user_df[UserCols.ID].isin(some_other_df[SomeOtherCols.ID])
]
```

### Step 5: Use for Validation (optional)

```python
if column not in UserCols.get_values():
raise ValueError(f"Invalid column: {column}. Valid: {UserCols.get_values()}")
```

---

## Rules and Conventions

1. **Location**: All constant classes go in a dedicated constants module
2. **Naming**:
- Class: `<Domain>Cols` for DataFrame columns, `<Domain>Keys` for config/dict keys
- Constants: `UPPER_SNAKE_CASE`
- Values: `lower_snake_case` strings
3. **Docstring**: Every class must have a docstring explaining its purpose
4. **Grouping**: Group related constants logically, e.g. for a specific type of DataFrame. Duplications (e.g. `Cols.ID` in case several dataframes share the same column) are allowed.
5. **Bracket notation**: Always use `df[ConstantClass.COLUMN]`, never attribute access `df.column`

---

## Examples

### Example 1: DataFrame Columns

```python
class OrderCols(metaclass=ConstantsClass):
"""String constants for order table columns."""

ORDER_ID = "order_id"
CUSTOMER_ID = "customer_id"
TOTAL = "total"
STATUS = "status"
```

**Usage**:
```python
from constants.keys import OrderCols

# DataFrame access
orders_df[OrderCols.ORDER_ID].values
orders_df[OrderCols.TOTAL].sum()

# Configuration
config = {
"group_by": [OrderCols.CUSTOMER_ID],
"aggregate": [OrderCols.TOTAL],
}
```

### Example 2: Allowed Values with Validation

```python
class OrderStatus(metaclass=ConstantsClass):
"""String constants for order status values."""

PENDING = "pending"
SHIPPED = "shipped"
DELIVERED = "delivered"
```

**Usage with validation**:
```python
from constants.keys import OrderStatus

if status not in OrderStatus.get_values():
raise ValueError(
f"Unknown status: {status}. "
f"Valid options are {OrderStatus.get_values()}"
)
```


---

## Checklist for Adding New Constants

- [ ] Identified all magic strings to replace
- [ ] Created class with `metaclass=ConstantsClass`
- [ ] Added descriptive docstring
- [ ] Used `UPPER_SNAKE_CASE` for constant names
- [ ] Used `lower_snake_case` for string values
- [ ] Added class to constants module
- [ ] Updated imports in affected files
- [ ] Replaced all magic strings with constant references
- [ ] Used bracket notation `df[Const.COL]` not attribute access
32 changes: 31 additions & 1 deletion alphadia/constants/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ class SearchStepFiles(metaclass=ConstantsClass):
FRAG_TRANSFER_FILE_NAME = "frag.transfer.parquet"


class PrecursorDfCols(metaclass=ConstantsClass):
"""String constants for accessing precursor dataframe columns."""

PRECURSOR_IDX = "precursor_idx"


class FeaturesDfCols(metaclass=ConstantsClass):
"""String constants for accessing feature dataframe columns."""

PRECURSOR_IDX = "precursor_idx"


class PsmDfCols(metaclass=ConstantsClass):
"""String constants for accessing psm dataframe columns."""

PRECURSOR_IDX = "precursor_idx"


class CandidatesDfCols(metaclass=ConstantsClass):
"""String constants for accessing candidates dataframe columns."""

PRECURSOR_IDX = "precursor_idx"


class FragmentDfCols(metaclass=ConstantsClass):
"""String constants for accessing fragment dataframe columns."""

PRECURSOR_IDX = "precursor_idx"


class InferenceStrategy(metaclass=ConstantsClass):
"""String constants for protein inference strategies."""

Expand Down Expand Up @@ -187,7 +217,7 @@ class StatCalibrationCols(metaclass=ConstantsClass):
INTERNAL_TO_OUTPUT_MAPPING = {
"peptide_lfq_intensity": PeptideOutputCols.INTENSITY,
"precursor_lfq_intensity": PrecursorOutputCols.INTENSITY,
"precursor_idx": PrecursorOutputCols.IDX,
"precursor_idx": PrecursorOutputCols.IDX, # precursor_idx: UNCLEAR
"elution_group_idx": PrecursorOutputCols.ELUTION_GROUP_IDX,
"rank": PrecursorOutputCols.RANK,
"naa": PrecursorOutputCols.NAA,
Expand Down
6 changes: 4 additions & 2 deletions alphadia/fdr/_fdrx/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import numpy as np
import pandas as pd

from alphadia.constants.keys import PsmDfCols


def get_pep(
psm_df: pd.DataFrame,
Expand Down Expand Up @@ -96,7 +98,7 @@ def add_q_values(
"""
EPSILON = 1e-6
df = df.sort_values(
[decoy_proba_column, decoy_column, "precursor_idx"], ascending=True
[decoy_proba_column, decoy_column, PsmDfCols.PRECURSOR_IDX], ascending=True
) # last sort to break ties

# translate the decoy probabilities to target probabilities
Expand Down Expand Up @@ -160,7 +162,7 @@ def keep_best(
group_columns = ["channel", "mod_seq_charge_hash"]
df = df.reset_index(drop=True)
df = df.sort_values(
[score_column, *group_columns, "precursor_idx"], ascending=True
[score_column, *group_columns, PsmDfCols.PRECURSOR_IDX], ascending=True
) # last sort to break ties
df = df.groupby(group_columns).head(1)
df = df.sort_index().reset_index(drop=True)
Expand Down
9 changes: 5 additions & 4 deletions alphadia/fdr/fdr.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import numpy as np
import pandas as pd

from alphadia.constants.keys import PsmDfCols
from alphadia.fdr.plotting import plot_fdr
from alphadia.fdr.utils import manage_torch_threads, train_test_split_
from alphadia.fragcomp.fragcomp import FragmentCompetition
Expand Down Expand Up @@ -133,13 +134,13 @@ def perform_fdr( # noqa: C901, PLR0913 # too complex, too many arguments
else ["elution_group_idx"]
)
else:
group_columns = ["precursor_idx"]
group_columns = [PsmDfCols.PRECURSOR_IDX]

predicted_proba = classifier.predict_proba(X)[:, 1]

psm_df["proba"] = predicted_proba
psm_df.sort_values(
["proba", "precursor_idx"], ascending=True, inplace=True
["proba", PsmDfCols.PRECURSOR_IDX], ascending=True, inplace=True
) # last sort to break ties

psm_df = get_q_values(psm_df, "proba", "_decoy")
Expand Down Expand Up @@ -206,7 +207,7 @@ def keep_best(

"""
if group_columns is None:
group_columns = ["channel", "precursor_idx"]
group_columns = ["channel", PsmDfCols.PRECURSOR_IDX]
df = df.reset_index(drop=True)
df = df.sort_values(
[score_column, *group_columns], ascending=True
Expand Down Expand Up @@ -272,7 +273,7 @@ def get_q_values(

"""
if extra_sort_columns is None:
extra_sort_columns = ["precursor_idx"]
extra_sort_columns = [PsmDfCols.PRECURSOR_IDX]

df = df.sort_values(
[score_column, decoy_column, *extra_sort_columns], ascending=True
Expand Down
8 changes: 4 additions & 4 deletions alphadia/fragcomp/fragcomp.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from alphatims import utils as timsutils
from pandas.errors import SettingWithCopyWarning

from alphadia.constants.keys import CalibCols
from alphadia.constants.keys import CalibCols, FragmentDfCols, PsmDfCols
from alphadia.fragcomp.utils import add_frag_start_stop_idx, candidate_hash
from alphadia.utils import USE_NUMBA_CACHING

Expand Down Expand Up @@ -255,18 +255,18 @@ def __call__(
warnings.simplefilter(action="ignore", category=(SettingWithCopyWarning))

psm_df["_candidate_idx"] = candidate_hash(
psm_df["precursor_idx"].values, psm_df["rank"].values
psm_df[PsmDfCols.PRECURSOR_IDX].values, psm_df["rank"].values
)
frag_df["_candidate_idx"] = candidate_hash(
frag_df["precursor_idx"].values, frag_df["rank"].values
frag_df[FragmentDfCols.PRECURSOR_IDX].values, frag_df["rank"].values
)

psm_df = add_frag_start_stop_idx(psm_df, frag_df)
psm_df = self._add_window_idx(psm_df, cycle)

# important to sort by window_idx and proba
psm_df.sort_values(
by=["window_idx", "proba", "precursor_idx"], inplace=True
by=["window_idx", "proba", PsmDfCols.PRECURSOR_IDX], inplace=True
) # last sort to break ties

valid = np.ones(len(psm_df)).astype(bool)
Expand Down
Loading
Loading