P4: Prediction levels
This protocol covers the three prediction tasks and levels (residue,
domain, protein) and how each one shapes df_parts. Before you can
ask CPP which physicochemical features separate your groups, you first
have to decide what one example is. Is it a single residue (a
cleavage site, a post-translational modification), one sub-region of a
protein (a transmembrane domain), or a whole chain (a short peptide)?
That choice is the prediction level, and in AAanalysis it is written
directly into the load_dataset() name prefix: AA_* (residue),
DOM_* (domain), SEQ_* (protein). The level is not a different
algorithm: all three feed the same run(). What changes is the
unit of comparison, and therefore how you build df_parts.
Choosing the prediction level fixes the unit of comparison (a
window at residue level, a part set at domain level, or the
whole chain at protein level), and that, in turn, fixes how the
parts are named and which kind of feature you can read out. Pick the
level from your biological question first; the df_parts construction
then follows mechanically.
Like Protocol 1, this is a determinant-discovery / prediction setup: you
contrast a test group (label=1) against a reference group
(label=0) and read out the signature, the set of
position-resolved physicochemical features that separates them. Getting
df_parts right is the single most common bottleneck for new users,
so this protocol shows the correct construction side by side for all
three levels.
When to use it. Use this protocol when you have a labelled two-group
task and are unsure whether it is residue, domain, or
protein level (or when you are building df_parts for the first
time and want a correct template per level). The level follows the
biological question:
Residue level (
AA_*): “What distinguishes a position from its neighbours?” The unit of comparison is a fixed-length window anchored at a residue or a scissile bond (e.g. protease cleavage, PTM sites).Domain level (
DOM_*): “What distinguishes one sub-region of a protein from the same region in another protein?” The unit is a part set derived fromtmd_start/tmd_stop(e.g. gamma-secretase substrates compared over their transmembrane domain), CPP’s native ground.Protein level (
SEQ_*): “What distinguishes one whole chain from another?” The unit is the whole sequence as a single part (e.g. aggregation-prone vs. soluble peptides).
When not to use it. This protocol is about choosing a level and
wiring df_parts: it is not where you sample a reference group. If
your residue-level task has no ready-made negatives (true for most
protease / PTM problems), you must first construct non-site windows; see
the Next step. And if you have only one group, or want to redesign a
sequence rather than contrast two sets, you are outside the
determinant-discovery setup this protocol assumes.
Input. A df_seq whose format matches the level (see
get_df_parts()), plus a binary label column (1
= test group, 0 = reference group). Remember that
load_dataset(name=..., n=N) returns 2N rows (N per group); read
group sizes from the label column, never from len(df_seq). The
level is encoded in the dataset name prefix (AA_* / DOM_* /
SEQ_*; see the data-loader tutorial for the full naming scheme).
Level |
Dataset |
|
Biological contrast (test=1 vs reference=0) |
|---|---|---|---|
residue |
|
s equence-based (9-residue windows around the scissile bond; even window = be tween-residues) |
cleaved ( |
domain |
|
p
osition-based
( |
gamma-secretase
substrate
( |
protein |
|
s equence-based (short full peptides) |
ag
gregation-prone
( |
These tables come from upstream. Domain- and protein-level tables are
loaded directly with load_dataset() (or your own annotation), while
residue-level windows are produced by the Construct sets & sampling
protocol (AAWindowSampler), which is also where you build the
reference group when no negatives exist.
import aaanalysis as aa
aa.options["verbose"] = False
aa.options["random_state"] = 42
sf = aa.SequenceFeature()
Run. The pipeline is identical at every level (get_df_parts ->
run() -> TreeModel ranking), and only df_parts (and the
matching split_kws) differ. We build the three df_parts first,
then run CPP on each.
Residue level: a window as a single part. The 9-residue window is
the unit of comparison, so we map the whole window onto one part. We
set jmd_n_len=0 / jmd_c_len=0 so no flanking is split off, and
select the single neutral part tmd, here just a placeholder name for
“the window”, not a transmembrane domain. (At the residue level the
principled naming follows the Schechter-Berger
... P2 . P1 | P1' . P2' ... convention around the scissile bond;
today parts are chosen from the predefined family, so we keep the
neutral tmd.)
# Every loader option by name; all but `name` and `n` sit at their default here.
df_res = aa.load_dataset(name="AA_CASPASE3", n=15,
random=False, # True would draw a different n per class on every call
non_canonical_aa="remove", # "keep" retains X / U / B; "gap" replaces them with "-"
min_len=None, max_len=None, # a sequence-length window; None keeps every entry
aa_window_size=9, # the window width the AA_* datasets are cut to
verbose=False)
labels_res = df_res["label"].to_list()
df_parts_res = sf.get_df_parts(df_seq=df_res, list_parts=["tmd"], all_parts=False,
jmd_n_len=0, jmd_c_len=0, # no flanking: the window is the whole part
tmd_len=None, # only used for the anchor-based (sequence + pos) format
remove_entries_with_gaps=False, replace_non_canonical_aa=False)
aa.display_df(df=df_parts_res, n_rows=5, show_shape=True)
DataFrame shape: (30, 1)
| tmd | |
|---|---|
| entry | |
| CASPASE3_1_pos126 | QTLRDSMLK |
| CASPASE3_1_pos127 | TLRDSMLKY |
| CASPASE3_2_pos116 | VDETDSGAG |
| CASPASE3_2_pos117 | DETDSGAGL |
| CASPASE3_2_pos223 | VDAVDTGIS |
Domain level: named parts from ``tmd_start`` / ``tmd_stop``. The
position-based format lets get_df_parts slice each sequence into the
named parts jmd_n / tmd / jmd_c (here the TMD is the
domain of interest). jmd_n_len / jmd_c_len set how many flanking
residues frame the domain.
df_dom = aa.load_dataset(name="DOM_GSEC", n=15)
labels_dom = df_dom["label"].to_list()
df_parts_dom = sf.get_df_parts(df_seq=df_dom, list_parts=["jmd_n", "tmd", "jmd_c"], all_parts=False,
jmd_n_len=10, jmd_c_len=10, # residues framing the domain on each side
tmd_len=None,
remove_entries_with_gaps=False, replace_non_canonical_aa=False)
aa.display_df(df=df_parts_dom, n_rows=5, show_shape=True)
DataFrame shape: (30, 3)
| jmd_n | tmd | jmd_c | |
|---|---|---|---|
| entry | |||
| Q14802 | NSPFYYDWHS | LQVGGLICAGVLCAMGIIIVMSA | KCKCKFGQKS |
| Q86UE4 | LGLEPKRYPG | WVILVGTGALGLLLLFLLGYGWA | AACAGARKKR |
| Q969W9 | FQSMEITELE | FVQIIIIVVVMMVMVVVITCLLS | HYKLSARSFI |
| P53801 | RWGVCWVNFE | ALIITMSVVGGTLLLGIAICCCC | CCRRKRSRKP |
| Q8IUW5 | NDTGNGHPEY | IAYALVPVFFIMGLFGVLICHLL | KKKGYRCTTE |
Protein level: the whole chain as a single part. For whole-chain
prediction the entire sequence is the part. As at the residue level we
set jmd_n_len=0 / jmd_c_len=0 and select the single neutral part
tmd, but here it spans the full peptide, not a window.
df_prot = aa.load_dataset(name="SEQ_AMYLO", n=15)
labels_prot = df_prot["label"].to_list()
df_parts_prot = sf.get_df_parts(df_seq=df_prot, list_parts=["tmd"], all_parts=False,
jmd_n_len=0, jmd_c_len=0, # the single part spans the full peptide
tmd_len=None,
remove_entries_with_gaps=False, replace_non_canonical_aa=False)
aa.display_df(df=df_parts_prot, n_rows=5, show_shape=True)
DataFrame shape: (30, 1)
| tmd | |
|---|---|
| entry | |
| AMYLO_1 | AAAQAA |
| AMYLO_2 | QSSYSS |
| AMYLO_3 | QSYGQQ |
| AMYLO_4 | QSYNPP |
| AMYLO_5 | QSYSGY |
Run CPP per level. split_kws must fit the part lengths
(segments/patterns cannot exceed the shortest part). For the short
residue window we use small positional splits; for the domain we
size splits to the 10-residue JMDs; for the protein we use a single
compositional Segment(1,1) whole-part average
(position-agnostic, composition-like).
Notice how the only thing that changes between the three runs is
df_parts (and the split_kws sized to it): the run() ->
TreeModel half is copy-paste identical at every level. To keep the
focus on the figure we show only the domain-level df_feat here, and
visualise that same signature as a feature map in the Output
section just below.
# Residue level: positional splits over the short window
split_kws_res = sf.get_split_kws(split_types=["Segment", "Pattern"], n_split_min=1,
n_split_max=4, steps_pattern=[1, 2], n_min=2, n_max=3, len_max=9,
steps_periodicpattern=None, # only used by the PeriodicPattern split type
strategy=None) # "compositional" / "positional" presets
cpp = aa.CPP(df_parts=df_parts_res, split_kws=split_kws_res)
df_feat_res = cpp.run(labels=labels_res, n_filter=20, n_jobs=1) # n_jobs=1 avoids a spawn issue on Python 3.14/macOS
X_res = sf.feature_matrix(features=df_feat_res["feature"], df_parts=df_parts_res)
tm = aa.TreeModel().fit(X_res, labels=labels_res)
df_feat_res = tm.add_feat_importance(df_feat=df_feat_res)
# Domain level: splits sized to the named parts (positional + compositional).
# This is the one call listing every public parameter by name, grouped, as a reference
# for the two levels above and below; all unnamed defaults are shown at their default value.
split_kws_dom = sf.get_split_kws(split_types=None, # None uses Segment + Pattern + PeriodicPattern
n_split_min=1, n_split_max=10, steps_pattern=[3, 4],
n_min=2, n_max=4, len_max=10, steps_periodicpattern=None, strategy=None)
cpp = aa.CPP(df_parts=df_parts_dom, split_kws=split_kws_dom,
df_scales=None, df_cat=None, # None loads the bundled AAontology scales and categories
accept_gaps=False, verbose=False, random_state=42,
bootstrap=False, bootstrap_kws=None) # True would add a feature-stability column
df_feat_dom = cpp.run(labels=labels_dom, label_test=1, label_ref=0,
# filtering: how many features survive, and how redundant they may be
n_filter=20, n_pre_filter=None, pct_pre_filter=5,
max_std_test=0.2, max_overlap=0.5, max_cor=0.5,
check_cat=True, parametric=False, redundancy="legacy",
# part geometry the feature positions are mapped onto
start=1, tmd_len=20, jmd_n_len=10, jmd_c_len=10,
# execution (n_jobs=1 avoids a spawn issue on Python 3.14/macOS)
n_jobs=1, vectorized=True, n_batches=None, n_sample_batches=None,
return_stats=False)
X_dom = sf.feature_matrix(features=df_feat_dom["feature"], df_parts=df_parts_dom,
df_scales=None, accept_gaps=False, n_jobs=1, batch=False,
df_seq=None, df_parts_kws=None) # the df_seq route builds df_parts on the fly
tm = aa.TreeModel(list_model_classes=None, list_model_kwargs=None, # None uses the default tree ensemble
is_preselected=None, verbose=False, random_state=42)
tm = tm.fit(X=X_dom, labels=labels_dom, n_rounds=5, use_rfe=False,
n_cv=5, n_feat_min=25, n_feat_max=75, metric="accuracy", step=None) # only used when use_rfe=True
df_feat_dom = tm.add_feat_importance(df_feat=df_feat_dom, drop=False, sort=False)
aa.display_df(df=df_feat_dom[["feature", "mean_dif", "abs_auc", "feat_importance"]], n_rows=5)
| feature | mean_dif | abs_auc | feat_importance | |
|---|---|---|---|---|
| 1 | TMD-Pattern(C,4,8)-ROBB760103 | 0.176000 | 0.467000 | 8.380000 |
| 2 | TMD-Pattern(C,4,8)-MUNV940101 | -0.134000 | 0.456000 | 6.685000 |
| 3 | TMD-Pattern(C,4,8)-RACS820114 | -0.191000 | 0.440000 | 6.238000 |
| 4 | TMD-Pattern(C,4,8)-RACS820113 | -0.262000 | 0.436000 | 4.690000 |
| 5 | TMD-Pattern(C,4,8)-NAGK730103 | -0.282000 | 0.431000 | 5.885000 |
# Protein level: compositional Segment(1,1) over the whole chain
split_kws_prot = sf.get_split_kws(split_types="Segment", n_split_min=1, n_split_max=1)
cpp = aa.CPP(df_parts=df_parts_prot, split_kws=split_kws_prot)
df_feat_prot = cpp.run(labels=labels_prot, n_filter=15, n_jobs=1) # n_jobs=1 avoids a spawn issue on Python 3.14/macOS
X_prot = sf.feature_matrix(features=df_feat_prot["feature"], df_parts=df_parts_prot)
tm = aa.TreeModel().fit(X_prot, labels=labels_prot)
df_feat_prot = tm.add_feat_importance(df_feat=df_feat_prot)
Output. Each level returns its own df_feat signature, one
row per selected feature, with the feature id reading as
Part-Split-Scale. The downstream columns are identical across
levels: mean_dif (signed test minus reference difference),
abs_auc (separation strength, the adjusted AUC* in [0, 0.5]
where 0 = no separation and 0.5 = perfect separation), and
feat_importance (tree-based rank). The only thing that changes is
how the feature id reads, because the parts differ; everything
downstream stays the same.
A note on the numbers. We deliberately use tiny
n=15-per-group fixtures so the protocol runs in seconds. On so few sequences it is trivial for top-ranked features to separate the two groups perfectly, so you will seeabs_aucsitting at or near0.5(especially at the residue level, where the short windows overfit most easily). Treat these values as illustrative of what the column means, not as a real effect size; on a full dataset the same ranking shows a graded spread ofabs_aucbelow0.5.
The fastest way to see a signature is the feature map (the same plot
as in Protocol 1). Below is the domain-level (gamma-secretase)
signature: the y-axis groups features by AAontology category, the
heatmap cells show mean_dif (red = higher in substrates, blue =
higher in non-substrates), and the bars on the right show
feat_importance.
import matplotlib.pyplot as plt
aa.plot_settings(font_scale=0.65, weight_bold=False)
cpp_plot = aa.CPPPlot() # feature_map is an INSTANCE method
cpp_plot.feature_map(df_feat=df_feat_dom, name_test="substrate", name_ref="non-substrate")
plt.tight_layout()
plt.show()
The feature ids read differently per level because the parts differ:
Level |
|
Example feature |
Reads as |
|---|---|---|---|
residue |
|
|
which positions in the window, which property |
domain |
|
|
which sub-region of the domain, which property |
protein |
|
|
a whole-chain composition property (no position) |
Visualise any level’s signature with
aa.CPPPlot().feature_map(df_feat=...) exactly as above: the plot
needs the feat_importance column we added with
add_feat_importance() (see P1: CPP signature for the full
feature_map parameter set). The table states the difference between
the levels; the figure below shows it, by drawing all three units of
comparison on one and the same protein.
The three levels, shown on one protein. The table above states that
the feature id reads differently per level; the figure below shows what
actually changes. All three units of comparison are drawn on the same
chain, BTC (entry P35070), a gamma-secretase substrate from
DOM_GSEC that carries an annotated TMD, so the residue window, the
named domain parts and the whole protein are all defined on it. The
property is held fixed too: one AAontology hydrophobicity scale
(ARGP820101) at every level. The top panel draws the unit of
comparison (every fourteenth window only, for legibility); the bottom
panel draws the value each level hands to CPP, which is the
Segment(1,1) mean of that scale over its unit.
import numpy as np
import pandas as pd
# One protein, three levels. BTC (entry P35070) comes from the same DOM_GSEC table used above.
df_one = df_dom[df_dom["entry"] == "P35070"]
seq = df_one["sequence"].iloc[0]
tmd_start, tmd_stop = int(df_one["tmd_start"].iloc[0]), int(df_one["tmd_stop"].iloc[0])
win_len, jmd_len, scale_id = 9, 10, "ARGP820101" # AAontology: Polarity / Hydrophobicity
df_scales = aa.load_scales(name="scales", just_aaindex=False, unclassified_out=False,
top60_n=None, # a top60_n subset is only used for scale benchmarking
top_explain_n=None, top_explain_min_th=None)
def scale_mean(df_parts, parts):
"""Mean of one scale over each part: exactly the Segment(1,1) feature CPP builds."""
features = [f"{p.upper()}-Segment(1,1)-{scale_id}" for p in parts]
X = sf.feature_matrix(features=features, df_parts=df_parts, df_scales=df_scales,
accept_gaps=False, n_jobs=1, batch=False, df_seq=None, df_parts_kws=None)
return np.asarray(X).ravel()
# Residue level: one 9-residue window per position, each window a unit of its own.
windows = [seq[i:i + win_len] for i in range(len(seq) - win_len + 1)]
df_parts_win = sf.get_df_parts_from_windows(dict_parts={"tmd": windows})
x_res = scale_mean(df_parts_win, ["tmd"])
pos_res = np.arange(len(windows)) + win_len // 2 + 1
# Domain level: the three named parts around the annotated TMD, one value each.
df_parts_one = sf.get_df_parts(df_seq=df_one, list_parts=["jmd_n", "tmd", "jmd_c"], all_parts=False,
jmd_n_len=jmd_len, jmd_c_len=jmd_len, tmd_len=None,
remove_entries_with_gaps=False, replace_non_canonical_aa=False)
x_dom = scale_mean(df_parts_one, ["jmd_n", "tmd", "jmd_c"])
spans_dom = [(tmd_start - jmd_len, tmd_start - 1), (tmd_start, tmd_stop), (tmd_stop + 1, tmd_stop + jmd_len)]
# Protein level: the whole chain as a single part, one value for the entire protein.
df_parts_chain = sf.get_df_parts(df_seq=df_one[["entry", "sequence"]], list_parts=["tmd"], all_parts=False,
jmd_n_len=0, jmd_c_len=0, tmd_len=None,
remove_entries_with_gaps=False, replace_non_canonical_aa=False)
x_prot = float(scale_mean(df_parts_chain, ["tmd"])[0])
aa.plot_settings(font_scale=0.9, weight_bold=False)
c_prot, c_dom, c_res = aa.plot_get_clist(n_colors=3) # gray, blue, red
c_dom_light = "#c5d2e8"
fig, axes = plt.subplots(2, 1, figsize=(10, 5.6), sharex=True, gridspec_kw={"height_ratios": [1.1, 1.6]})
# Panel A: the unit of comparison at each level, on the same chain.
ax = axes[0]
ax.broken_barh([(1, len(seq))], (-0.22, 0.44), color=c_prot, zorder=2)
ax.broken_barh([(1, len(seq))], (0.93, 0.14), color="0.9", zorder=1)
ax.broken_barh([(s, e - s + 1) for s, e in [spans_dom[0], spans_dom[2]]], (0.78, 0.44), color=c_dom_light, zorder=2)
ax.broken_barh([(spans_dom[1][0], spans_dom[1][1] - spans_dom[1][0] + 1)], (0.78, 0.44), color=c_dom, zorder=2)
ax.broken_barh([(1, len(seq))], (1.93, 0.14), color="0.9", zorder=1)
ax.broken_barh([(p - win_len // 2, win_len) for p in pos_res[::14]], (1.78, 0.44),
color=c_res, edgecolor="white", linewidth=0.8, zorder=2)
ax.set_yticks([2, 1, 0])
ax.set_yticklabels([f"Residue\n({len(windows)} windows)", "Domain\n(3 parts)", "Protein\n(1 chain)"],
size=aa.plot_gcfs() - 2)
ax.set_ylim(-0.8, 2.8)
ax.set_title("The unit of comparison on one protein (BTC, a gamma-secretase substrate)", size=aa.plot_gcfs())
ax.spines[["top", "right", "left"]].set_visible(False)
ax.tick_params(axis="y", length=0)
# Panel B: the same scale, aggregated by each level's unit.
ax = axes[1]
ax.axvspan(tmd_start, tmd_stop, color=c_dom, alpha=0.12, lw=0)
ax.plot(pos_res, x_res, color=c_res, lw=1.4, zorder=3)
for (start_pos, stop_pos), value in zip(spans_dom, x_dom):
ax.hlines(value, start_pos, stop_pos, color=c_dom, lw=5, zorder=4)
ax.hlines(x_prot, 1, len(seq), color=c_prot, lw=3, zorder=2)
ax.set_xlim(0, len(seq) + 1)
ax.set_xlabel("Residue position")
ax.set_ylabel(f"Hydrophobicity\n({scale_id})")
ax.spines[["top", "right"]].set_visible(False)
aa.plot_legend(ax=ax, dict_color={f"Residue: {win_len}-residue window": c_res,
"Domain: named part": c_dom,
"Protein: whole chain": c_prot},
loc="upper left", loc_out=True, y=-0.38, x=0, n_cols=3, fontsize=aa.plot_gcfs() - 2)
plt.tight_layout()
plt.show()
# The same picture as numbers: what one unit covers, and how many of them this protein has.
df_levels = pd.DataFrame({
"level": ["residue (AA_*)", "domain (DOM_*)", "protein (SEQ_*)"],
"unit of comparison": [f"{win_len}-residue window", "named part", "whole chain"],
"units on this protein": [len(windows), len(x_dom), len(df_parts_chain)],
"hydrophobicity": [f"{x_res.min():.2f} to {x_res.max():.2f}",
", ".join(f"{v:.2f}" for v in x_dom), f"{x_prot:.2f}"],
"one value covers": [f"{win_len} residues", f"{jmd_len} to {tmd_stop - tmd_start + 1} residues",
f"{len(seq)} residues"],
})
aa.display_df(df=df_levels, n_rows=10, show_shape=True, char_limit=40)
DataFrame shape: (3, 5)
| level | unit of comparison | units on this protein | hydrophobicity | one value covers | |
|---|---|---|---|---|---|
| 1 | residue (AA_*) | 9-residue window | 170 | 0.11 to 0.64 | 9 residues |
| 2 | domain (DOM_*) | named part | 3 | 0.33, 0.54, 0.37 | 10 to 23 residues |
| 3 | protein (SEQ_*) | whole chain | 1 | 0.35 | 178 residues |
Notice how one protein and one property give three different read-outs. At residue level the scale stays resolved: the overlapping windows trace a profile, so a feature can say at which position the property is high, which is why residue signatures are positional. At domain level that profile collapses into three numbers, one per named part, and the TMD part sits far above both flanking JMDs: a feature can still say which sub-region, but no longer which residue. At protein level everything collapses into a single number for the chain, which lands below the TMD it contains, because the hydrophobic domain is averaged together with the soluble rest of the protein: position is gone, so protein-level signatures can only be compositional. Choosing a level is choosing how much of that resolution you keep, and the table puts the same three read-outs side by side as numbers.
How to interpret. Read each output column as follows:
Output |
Non-expert reading |
|---|---|
|
stronger group-separating
property; |
positive |
property is higher in the test group (label=1) |
negative |
property is higher in the reference group (label=0) |
a |
a compositional signal (it does not depend on position) |
a |
a positional signal (it depends on where in the part) |
The level constrains the kind of feature you can read, which is exactly what the three-level figure above makes visible: the residue profile keeps position, the three domain parts keep sub-region, and the single whole-chain value keeps neither.
Residue signatures are purely positional: e.g. CASPASE3 cleavage depends on which flanking position (P2, P3, P4 …) carries which property around the scissile bond.
Protein signatures are purely compositional: amyloid aggregation depends on overall composition, not position, so only
Segment(1,1)features appear.Domain signatures mix both: the gamma-secretase TMD shows positional sub-region effects and whole-part composition.
Key takeaways
The prediction level you pick fixes the unit of comparison and how parts are named: it is a modelling decision, not an algorithm switch; all three levels call the same
run().The level constrains the kind of signature: residue is purely positional, protein is purely compositional, domain mixes both. The figure shows why, on one protein: the same scale keeps its position profile over windows, collapses to three part means over the domain, and collapses to a single number over the chain.
Always read the signature (coherent blocks of related features), never a single cell, and on tiny fixtures read
abs_aucas illustrative, not as a real effect size.
Common mistakes.
Calling ``CPP(df_seq=…)``:
CPPtakesdf_parts; always build them withget_df_parts()first (true at every level).Forgetting that “part” means different things per level: a window (residue), a named sub-region (domain), or the whole chain (protein). Name and interpret it accordingly.
Using positional splits at the protein level: whole-chain composition is best captured by the compositional
Segment(1,1); multi-segment /Patternsplits over-fragment a position-agnostic signal.Letting ``split_kws`` exceed the shortest part: segments and patterns cannot be longer than the smallest part (e.g. a 10-residue JMD or a 9-residue window), or
CPPraises a “too short sequences” error. Sizen_split_max/len_maxto the parts.Confusing residue with domain: both can use a single
tmdpart, but residue windows are short, fixed-length, and anchored at a site, while domain parts come fromtmd_start/tmd_stopand carry flanking JMDs (jmd_n_len/jmd_c_len).
Next step. Continue with P5: Engineer features to turn the chosen
prediction level and its df_parts into an engineered CPP feature
set.