Skip to content

feat(encoders)!: one-hot encode and tokenize with Encoder and Tokenizer - #12

Open
Hoeze wants to merge 7 commits into
ci/minor-before-1.0from
feat/one-hot-case-insensitive
Open

Hoeze wants to merge 7 commits into
ci/minor-before-1.0from
feat/one-hot-case-insensitive

Conversation

@Hoeze

@Hoeze Hoeze commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

tl;dr: the new module kipoiseq2.encoders has the frozen classes Encoder and Tokenizer, which replace one_hot, tokenize and their helpers. An Alphabet holds the letters and the letters that each ambiguity code stands for. The rule ambiguous of Encoder sets the rows of the codes: "zeros", "fractional" or "ones". Tokenizer gives each code its own token. The lookup is case-sensitive, and a letter that is not in the Alphabet raises a ValueError that names it.

Encoder(alphabet=DNA, *, ambiguous="zeros", dtype=np.uint8) one-hot encodes with one_hot(seq). It returns an array of shape (L, len(alphabet.letters)). A letter gives 1 in its column, and a code of k letters gives the row of the rule ambiguous:

  • "zeros" gives a row of zeros.
  • "fractional" gives 1/k in the columns of the letters of the code, so N gives 0.25 in every DNA column and R gives [0.5, 0, 0.5, 0]. It needs a floating-point dtype.
  • "ones" gives 1 in the columns of the letters of the code, in any dtype, so N gives [1, 1, 1, 1] and R gives [1, 0, 1, 0]. It keeps the letters of each code in integer dtypes. Its rows do not sum to 1, so a model trained on one-hot input sees N as a strong signal.

decode(x) converts the array back to a str. It maps each row to the letter or code with this row, and the first code wins if several codes share a row. With "zeros", all codes share the row of zeros, so this row becomes the first code, e.g. N. With "fractional" and "ones", each code has its own row, so decode(one_hot(seq)) equals seq. decode raises a ValueError for any other row.

Tokenizer(alphabet=DNA, *, token_map=None, dtype=np.uint8) converts a sequence to integer tokens with tokenize(seq). By default, letters[i] becomes the token i, and each code becomes a token of its own after the letters, in the order of the codes. For DNA, N is 4, R is 5 and V is 14. So the token of a letter or code is the index of its row in the table of Encoder. token_map sets other tokens, e.g. the vocabulary of a pretrained model, and several letters and codes can share one. Its keys are the letters and the codes of the alphabet, so 15 keys for DNA.

Both constructors check the configuration once. They raise a TypeError for a wrong type, also for dtype=None, which numpy would silently read as float64. They raise a ValueError for a rule other than "zeros", "fractional" or "ones", for "fractional" with a dtype that is not a floating-point type, or if the keys of token_map are not the letters and the codes. Both classes compare by their configuration, hash, have a repr and pickle, e.g. for the worker processes of a data loader. They are safe as default arguments.

The new module kipoiseq2.alphabets has the frozen dataclass Alphabet(letters, ambiguous={code: letters}) and the presets DNA, RNA and AMINO_ACIDS. DNA and RNA have N and the other IUPAC ambiguity codes, because GRCh37 and GRCh38 contain a few of them. AMINO_ACIDS has the 20 standard amino acids as letters and the codes X, B and Z. For a vocabulary without the IUPAC codes, e.g. the one of HyenaDNA, use the strict Alphabet(["A", "C", "G", "T"], ambiguous={"N": ["A", "C", "G", "T"]}). Alphabet raises a TypeError for a str, which the old functions read in two different ways. It raises a ValueError for letters that are empty, differ in length or repeat. It also raises a ValueError for a code that is a letter, differs in length from the letters, or stands for fewer than 2 letters or for letters outside the alphabet. Two alphabets are equal only with their codes in the same order, because the order sets the tokens. The package kipoiseq2 exports Alphabet, the presets, Encoder and Tokenizer.

The lookup is case-sensitive, so a lowercase letter of a soft-masked repeat is unknown unless the alphabet contains it. A letter that is not in the alphabet raises a ValueError that names it. For ASCII lowercase letters whose uppercase form is in the alphabet, the message points to seq.upper() and to FastaStringExtractor. Before, one_hot_dna and OneHot() encoded unknown and lowercase letters as rows of zeros, and the other code paths raised a KeyError. Now DNA and RNA encode the IUPAC ambiguity codes by the rule ambiguous, and all other unknown letters raise.

For letters of one ASCII character, the lookup translates the bytes of the sequence with a table of 256 bytes and takes the rows of the result. decode builds the str from the bytes of these letters instead of joining them. Other letters, e.g. codons, use a dict and a join. decode checks the rows with a loop over the columns, because numpy reduces an axis of a few values slowly. With "fractional" and "ones", it compares only the rows that are not the row of a letter with the rows of the codes. The times per call for random ACGTN are for 1 kb and for 196,608 bases, on Python 3.14 and numpy 2.5. Each case ran in its own process, 3 times. Each time is the median of these 3 processes, and each process reports the median of 21 runs:

  • Tokens: 1.14 us and 58 us.
  • One-hot in the default uint8: 2.58 us and 197 us. "ones" takes the same time.
  • One-hot in float32 with "fractional": 2.66 us and 229 us.
  • decode in uint8: 26 us and 2.2 ms with "zeros", and 35 us and 2.6 ms with "ones".
  • decode in float32 with "fractional": 36 us and 0.96 ms.

ReorderedOneHot is gone, because np.expand_dims and np.swapaxes give each of its layouts from the Encoder.one_hot array. tokenize, token2one_hot, one_hot2token and one_hot2string are gone, because Tokenizer and Encoder.decode replace them. Letters of different lengths and a len(seq) that is not a multiple of the letter length raise a ValueError instead of an AssertionError.

With -1, numpy indexing took the last row, so np.eye(4)[tokens] silently encoded N as T. Now a table with one row per letter raises an IndexError for the token of a code. An embedding with len(alphabet.letters) + len(alphabet.ambiguous) rows takes the tokens as they are.

README.md shows the encoders and states the case rule. MIGRATING.md lists each removed name with its replacement.

BREAKING CHANGE: one_hot_dna, one_hot, tokenize, token2one_hot, one_hot2token, one_hot2string, OneHot and ReorderedOneHot are removed. Use Encoder(alphabet, ...).one_hot(seq), Encoder.decode and Tokenizer(alphabet, ...).tokenize(seq) instead, and np.expand_dims and np.swapaxes instead of ReorderedOneHot. They take an Alphabet instead of alphabet, neutral_alphabet or alphabet_size. A neutral letter becomes an ambiguity code for all letters. The presets of kipoiseq2.alphabets replace kipoiseq2.utils.DNA, RNA, AMINO_ACIDS and alphabets. AMINO_ACIDS.letters holds 20 amino acids instead of 22, because B and Z are codes now. Encoder returns uint8 and encodes the codes as rows of zeros by default. Encoder(DNA, ambiguous="fractional", dtype=np.float32) gives the old result of one_hot_dna for N. neutral_value is gone: 0 becomes ambiguous="zeros", 0.25 becomes ambiguous="fractional" with a float dtype, and 1 becomes ambiguous="ones". The encoders are case-sensitive, so letters that are not in the alphabet, also lowercase ones, raise a ValueError. Uppercase the sequence first, e.g. with seq.upper(). With DNA and RNA, the IUPAC ambiguity codes encode by the rule ambiguous. With "fractional", they give fractions of their bases, where one_hot_dna gave rows of zeros. A token_map needs a key for each letter and code, so 15 keys for DNA. Tokenizer encodes each code as a token of its own after the letters instead of -1, and it returns uint8 by default. decode takes the array of one sequence and returns a str.

feat!: add kipoiseq2.alphabets with Alphabet and its presets
feat(transforms)!: remove ReorderedOneHot
fix(alphabets): raise ValueError for letters of different lengths

Hoeze added 4 commits October 15, 2026 23:14
…rs in one-hot encoding

one_hot_dna, one_hot, tokenize, OneHot and ReorderedOneHot now encode a
lowercase letter like its uppercase form. A letter in neither alphabet
nor neutral_alphabet raises a ValueError that names it. Before,
one_hot_dna and OneHot() turned both into rows of zeros, while one_hot,
tokenize and OneHot with other arguments raised a KeyError. So soft-masked
repeats became rows of zeros, and the result of OneHot depended on its
code path.

one_hot_dna stays a vectorized table lookup, and its table now has a row
for each of the 256 byte values. Sequences with unknown or non-ASCII
letters go through one_hot, so both functions give the same values and
errors. Letters that differ only in case but encode differently, e.g. in
alphabet="ACGTacgt", raise a ValueError.

one_hot and tokenize now also accept an empty sequence instead of
raising an IndexError.

BREAKING CHANGE: lowercase letters encode like uppercase ones, and
letters in neither alphabet nor neutral_alphabet raise a ValueError
instead of encoding as rows of zeros or raising a KeyError. To encode
IUPAC codes such as R or Y like N, add them to neutral_alphabet, e.g.
neutral_alphabet=list("NRYSWKMBDHV"). MIGRATING.md lists the changes.
The transforms now differ from kipoiseq, so the README points to
MIGRATING.md for the differences.
…strings

A str was ambiguous: one_hot_dna read neutral_alphabet="NRY" as three
letters, while one_hot, tokenize and OneHot read it as one letter of
three characters and failed with an empty AssertionError.

one_hot_dna, one_hot, tokenize, one_hot2string, OneHot and
ReorderedOneHot now take alphabet and neutral_alphabet only as a list or
tuple of strings. They raise a TypeError for a str, for another type and
for elements that are not strings. The message of the TypeError names
both readings of the str, e.g. list("NRY") or ["NRY"]. An empty
neutral_alphabet stays allowed.

The defaults of neutral_alphabet become ("N",), and calls with the
defaults return the same arrays as before. one_hot_dna no longer splits
the elements of neutral_alphabet into characters, and ReorderedOneHot no
longer calls utils.parse_alphabet.

BREAKING CHANGE: alphabet and neutral_alphabet must be lists or tuples
of strings, and a str raises a TypeError. alphabet="ACGT" becomes
alphabet=list("ACGT"), and neutral_alphabet="N" becomes
neutral_alphabet=["N"]. one_hot_dna(seq, neutral_alphabet=["NRY"]) now
raises a ValueError: pass list("NRY") instead.
tokenize checked its input with bare assert statements. So
one_hot("ACGT", neutral_alphabet=["NRY"]) failed with an empty
AssertionError, and under python -O the checks did not run.

tokenize, and so one_hot, OneHot and ReorderedOneHot, now raise a
ValueError if the letters of alphabet and neutral_alphabet differ in
length, or if len(seq) is not a multiple of the letter length. The
message gives alphabet and neutral_alphabet, or len(seq). For an invalid
alphabet_axis, ReorderedOneHot raises a ValueError instead of an
AssertionError too.
@Hoeze
Hoeze added this pull request to stack #16 October 15, 2026 22:32
@Hoeze
Hoeze force-pushed the feat/one-hot-case-insensitive branch from 78b78f8 to 6c7fc2a Compare October 16, 2026 19:49
from dataclasses import dataclass


def _as_letters(letters: object, name: str) -> tuple[str, ...]:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the name parameter and give the function a better name

ReorderedOneHot served the axis layouts of the kipoi dataloaders.
np.expand_dims and np.swapaxes give each of these layouts from the
one-hot array, and a test checks their shapes. MIGRATING.md shows the
replacement.

BREAKING CHANGE: ReorderedOneHot is removed. Reorder the axes of the
one-hot array x with numpy instead, e.g.
ReorderedOneHot(alphabet_axis=0, dummy_axis=1) becomes
np.swapaxes(np.expand_dims(x, 1), 0, 2). get_output_shape has no
replacement.
@Hoeze
Hoeze force-pushed the feat/one-hot-case-insensitive branch from 71975ea to 377e2f2 Compare October 17, 2026 14:08
@Hoeze Hoeze changed the title feat(transforms)!: one-hot ignores case and rejects unknown letters feat(encoders)!: one-hot encode and tokenize with Encoder and Tokenizer Oct 17, 2026
The new module kipoiseq2.alphabets holds the letters of the encodings.
Alphabet is a frozen dataclass. It holds the letters in the order of the
one-hot columns and the tokens. Its mapping ambiguous holds the letters
that each ambiguity code stands for, e.g. R for A or G. A code for all
letters, e.g. N, is neutral.

Alphabet checks the letters and the codes once. It raises a TypeError
for a str, which can mean several letters or one letter of several
characters, and for letters that are not strings. It raises a ValueError
for no letters, empty letters, letters of different lengths and letters
that repeat. It also raises a ValueError for a code that is a letter or
that differs in length from the letters, and for a code with fewer than
2 letters, with a repeated letter or with a letter that is not in the
alphabet. It compares letters exactly, so "A" and "a" are different
letters.

- Alphabet stores the letters of each code in the order of the letters,
  so equal sets compare equal. It keeps the order of the codes, because
  this order sets their tokens. So two alphabets are equal only if their
  codes have the same order.
- The presets DNA and RNA have N and the other IUPAC ambiguity codes,
  because GRCh37 and GRCh38 contain a few of them. N comes first.
- AMINO_ACIDS has the 20 standard amino acids as letters, and X, B and Z
  as ambiguity codes.
- The package kipoiseq2 exports Alphabet and the presets.
- The docs show the defaults as in the source, e.g. DNA.letters instead
  of the tuple of letters.

BREAKING CHANGE: kipoiseq2.utils.DNA, RNA, AMINO_ACIDS and alphabets
are removed. Use the presets DNA, RNA and AMINO_ACIDS of
kipoiseq2.alphabets instead. Their letters are DNA.letters and so on.
AMINO_ACIDS.letters holds 20 amino acids instead of 22, because B and Z
are ambiguity codes now.
@Hoeze
Hoeze force-pushed the feat/one-hot-case-insensitive branch from 377e2f2 to 6c7d344 Compare October 17, 2026 16:22
The new module kipoiseq2.encoders has two frozen classes. Encoder
one-hot encodes sequences, and Tokenizer converts them to integer
tokens. Each instance holds the complete configuration of its output.
Its constructor checks the configuration and builds the lookup once, so
its method only applies it. The package kipoiseq2 exports both classes.

- Encoder(alphabet=DNA, *, ambiguous="zeros", dtype=np.uint8) has the
  method one_hot(seq). It returns an array of shape
  (L, len(alphabet.letters)). The rule ambiguous sets the rows of the
  ambiguity codes of the alphabet. "zeros" gives a row of zeros.
  "fractional" gives 1/k in the columns of the k letters of a code, so
  N gives 0.25 in every DNA column and R gives [0.5, 0, 0.5, 0].
  "ones" gives 1 in each of these k columns, in any dtype, so N gives
  [1, 1, 1, 1] and R gives [1, 0, 1, 0]. It keeps the letters of each
  code in integer dtypes, but its rows do not sum to 1.
- The method decode(x) converts the array back. It maps a row to the
  letter or code with this row. With "zeros", all codes share the row
  of zeros, so this row becomes the first code, e.g. N. With
  "fractional" and "ones", each code has its own row. decode raises a
  ValueError for any other row.
- Tokenizer(alphabet=DNA, *, token_map=None, dtype=np.uint8) has the
  method tokenize(seq). It maps letters[i] to i, and each ambiguity code
  to a token of its own after the letters, in the order of the codes.
  For DNA, N is 4, R is 5 and V is 14. So the token of a letter or code
  is the index of its row in the table of Encoder. token_map sets other
  tokens, e.g. the vocabulary of a pretrained model, and several letters
  and codes can share one. Its keys are the letters and the codes of the
  alphabet, so 15 keys for DNA.
- The constructors raise a TypeError for an argument of a wrong type,
  also for dtype=None, which numpy reads as float64. They raise a
  ValueError for another rule than "zeros", "fractional" or "ones", for
  "fractional" with a dtype that is not a floating-point type, or if the
  keys of token_map are not the letters and the codes.
- Both classes compare by their configuration, hash and pickle, e.g. for
  the worker processes of a data loader. They are safe as default
  arguments.
- The lookup is case-sensitive, so a lowercase letter no longer encodes
  like its uppercase form. A letter that is not in the alphabet raises
  a ValueError that names it. If the alphabet has its uppercase form,
  the message points to seq.upper() and to
  FastaStringExtractor(force_upper=True).
- For letters of one ASCII character, the lookup translates the bytes of
  the sequence with a table of 256 bytes and takes the rows of the
  result. decode builds the str from the bytes of these letters
  instead of joining them. Other letters, e.g. codons, use a dict and
  a join.
- decode checks the rows with a loop over the columns, because numpy
  reduces an axis of a few values slowly. With "fractional" and "ones",
  it compares only the rows that are not the row of a letter with the
  rows of the codes.

The times per call for random ACGTN, against fa84de8, an earlier
version of this change, are medians of many calls:

  1 kb, tokens               4.0 us -> 1.1 us
  1 kb, one-hot float32      4.0 us -> 2.6 us, or 2.4 us in uint8
  196,608 bases, tokens      202 us -> 55 us
  196,608 bases, one-hot     229 us -> 204 us, or 183 us in uint8

The ambiguity codes replace the neutral letters of fa60431, an earlier
version of this change. Against fa60431, tokens and one-hot keep their
times. decode gets faster, except in float32 for 1 kb. float32 means
neutral_value=0.25 before and "fractional" after. Each case ran in its
own process, alternating before and after, 3 times. The times are the
medians of these 3 processes, each the median of 21 runs:

  1 kb, tokens               1.13 us -> 1.14 us
  1 kb, one-hot uint8        2.55 us -> 2.58 us
  1 kb, one-hot float32      2.65 us -> 2.66 us
  1 kb, decode uint8         33 us -> 26 us
  1 kb, decode float32       34 us -> 36 us
  196,608 bases, tokens      59 us -> 58 us
  196,608 bases, one-hot     196 us -> 197 us in uint8
  196,608 bases, one-hot     228 us -> 229 us in float32
  196,608 bases, decode      2.8 ms -> 2.2 ms in uint8
  196,608 bases, decode      1.27 ms -> 0.96 ms in float32

With "ones" in uint8, one-hot takes 2.57 us for 1 kb and 197 us for
196,608 bases, and decode takes 35 us and 2.6 ms.

README.md shows the encoders and states the case rule. MIGRATING.md
maps each removed name to its replacement.

BREAKING CHANGE: one_hot_dna, one_hot, tokenize, token2one_hot,
one_hot2token, one_hot2string and OneHot are removed. Use
Encoder(alphabet, ...).one_hot(seq), Encoder.decode and
Tokenizer(alphabet, ...).tokenize(seq) instead. They take an Alphabet
instead of alphabet and neutral_alphabet. A neutral letter becomes an
ambiguity code for all letters. Encoder returns uint8 and encodes the
codes as rows of zeros by default, and
Encoder(DNA, ambiguous="fractional", dtype=np.float32) gives the old
result for N. neutral_value is gone: 0 becomes ambiguous="zeros", 0.25
becomes ambiguous="fractional" with a float dtype, and 1 becomes
ambiguous="ones". The encoders are case-sensitive, so lowercase letters
raise a ValueError. one_hot_dna encoded unknown letters as rows of
zeros. With DNA, the IUPAC ambiguity codes now encode by the rule
ambiguous, and other unknown letters raise a ValueError. Tokenizer
encodes each code as a token of its own after the letters instead of -1.
@Hoeze
Hoeze force-pushed the feat/one-hot-case-insensitive branch from 6c7d344 to affe21f Compare October 17, 2026 17:19

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant