ludwig-ai--ludwig
593b94c120
pytest / Unit Tests (push) Has been cancelled
pytest / Integration (integration_tests_a) (push) Has been cancelled
pytest / Integration (integration_tests_b) (push) Has been cancelled
pytest / Integration (integration_tests_c) (push) Has been cancelled
pytest / Integration (integration_tests_d) (push) Has been cancelled
pytest / Integration (integration_tests_e) (push) Has been cancelled
pytest / Integration (integration_tests_f) (push) Has been cancelled
pytest / Integration (integration_tests_g) (push) Has been cancelled
pytest / Integration (integration_tests_h) (push) Has been cancelled
pytest / Integration (integration_tests_i) (push) Has been cancelled
pytest / Integration (integration_tests_j) (push) Has been cancelled
pytest / Distributed (distributed_a) (push) Has been cancelled
pytest / Distributed (distributed_b) (push) Has been cancelled
pytest / Distributed (distributed_c) (push) Has been cancelled
pytest / Distributed (distributed_d) (push) Has been cancelled
pytest / Distributed (distributed_e) (push) Has been cancelled
pytest / Distributed (distributed_f) (push) Has been cancelled
pytest / Minimal Install (push) Has been cancelled
pytest / Event File (push) Has been cancelled
pytest (slow) / py-slow (push) Has been cancelled
Publish JSON Schema / publish-schema (push) Has been cancelled
525 行
22 KiB
Python
525 行
22 KiB
Python
#! /usr/bin/env python
|
|
# Copyright (c) 2023 Predibase, Inc., 2019 Uber Technologies, Inc.
|
|
#
|
|
# 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.
|
|
# ==============================================================================
|
|
|
|
import logging
|
|
from functools import partial
|
|
|
|
import numpy as np
|
|
import torch
|
|
|
|
from ludwig.constants import (
|
|
COLUMN,
|
|
LAST_PREDICTIONS,
|
|
LENGTHS,
|
|
NAME,
|
|
PREDICTIONS,
|
|
PROBABILITIES,
|
|
PROBABILITY,
|
|
PROC_COLUMN,
|
|
SEQUENCE,
|
|
)
|
|
from ludwig.features.base_feature import (
|
|
BasePostprocessingModule,
|
|
BasePreprocessingModule,
|
|
FeaturePreprocessingMixin,
|
|
InputFeature,
|
|
OutputFeature,
|
|
PredictModule,
|
|
)
|
|
from ludwig.features.feature_utils import compute_sequence_probability, compute_token_probabilities
|
|
from ludwig.schema.features.sequence_feature import SequenceInputFeatureConfig, SequenceOutputFeatureConfig
|
|
from ludwig.types import (
|
|
FeatureMetadataDict,
|
|
FeaturePostProcessingOutputDict,
|
|
ModelConfigDict,
|
|
PreprocessingConfigDict,
|
|
TrainingSetMetadataDict,
|
|
)
|
|
from ludwig.utils import output_feature_utils
|
|
from ludwig.utils.math_utils import softmax
|
|
from ludwig.utils.strings_utils import (
|
|
build_sequence_matrix,
|
|
create_vocabulary,
|
|
SpecialSymbol,
|
|
START_SYMBOL,
|
|
STOP_SYMBOL,
|
|
UNKNOWN_SYMBOL,
|
|
)
|
|
from ludwig.utils.tokenizers import get_tokenizer_from_registry
|
|
from ludwig.utils.types import PreprocessingInput
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _SequencePreprocessing(BasePreprocessingModule):
|
|
"""Torchscript-enabled version of preprocessing done by SequenceFeatureMixin.add_feature_data."""
|
|
|
|
def __init__(self, metadata: TrainingSetMetadataDict):
|
|
super().__init__()
|
|
self.lowercase = metadata["preprocessing"]["lowercase"]
|
|
self.tokenizer_type = metadata["preprocessing"]["tokenizer"]
|
|
self.tokenizer = get_tokenizer_from_registry(self.tokenizer_type)(
|
|
pretrained_model_name_or_path=metadata["preprocessing"].get("pretrained_model_name_or_path", None)
|
|
)
|
|
|
|
if not isinstance(self.tokenizer, torch.nn.Module):
|
|
raise ValueError(f"tokenizer must be a torch.nn.Module, got {self.tokenizer}")
|
|
|
|
self.padding_symbol = metadata["preprocessing"]["padding_symbol"]
|
|
self.unknown_symbol = metadata["preprocessing"]["unknown_symbol"]
|
|
self.start_symbol = START_SYMBOL
|
|
self.stop_symbol = STOP_SYMBOL
|
|
self.max_sequence_length = int(metadata["max_sequence_length"])
|
|
self.unit_to_id = metadata["str2idx"]
|
|
self.computed_fill_value = metadata["preprocessing"]["computed_fill_value"]
|
|
|
|
def forward(self, v: PreprocessingInput) -> torch.Tensor:
|
|
"""Takes a list of strings and returns a tensor of token ids."""
|
|
if not torch.jit.isinstance(v, list[str]):
|
|
raise ValueError(f"Unsupported input: {v}")
|
|
|
|
futures: list[torch.jit.Future[torch.Tensor]] = []
|
|
for sequence in v:
|
|
futures.append(
|
|
torch.jit.fork(
|
|
self._process_sequence,
|
|
sequence,
|
|
)
|
|
)
|
|
|
|
sequence_matrix = []
|
|
for future in futures:
|
|
sequence_matrix.append(torch.jit.wait(future))
|
|
|
|
return torch.stack(sequence_matrix)
|
|
|
|
def _process_sequence(self, sequence: str) -> torch.Tensor:
|
|
sequence = self.computed_fill_value if sequence == "nan" else sequence
|
|
|
|
# If tokenizer is HF, we defer lowercase transformation to the tokenizer.
|
|
if self.lowercase and self.tokenizer_type != "hf_tokenizer":
|
|
sequence_str: str = sequence.lower()
|
|
else:
|
|
sequence_str: str = sequence
|
|
|
|
sequence_vector = torch.full([self.max_sequence_length], self.unit_to_id[self.padding_symbol])
|
|
|
|
if self.tokenizer_type == "hf_tokenizer":
|
|
# Handles start, stop, and unknown symbols implicitly
|
|
unit_sequence = self.tokenizer(sequence)
|
|
assert torch.jit.isinstance(unit_sequence, list[int])
|
|
# Ensures that the sequence lengths are aligned between the input and output tensors.
|
|
sequence_length = min(len(unit_sequence), self.max_sequence_length)
|
|
sequence_vector[:sequence_length] = torch.tensor(unit_sequence)[:sequence_length]
|
|
return sequence_vector
|
|
|
|
# If tokenizer is not HF, we manually convert tokens to IDs and insert start, stop, and unknown symbols.
|
|
unit_sequence = self.tokenizer(sequence_str)
|
|
assert torch.jit.isinstance(unit_sequence, list[str])
|
|
|
|
sequence_vector[0] = self.unit_to_id[self.start_symbol]
|
|
if len(unit_sequence) + 1 < self.max_sequence_length:
|
|
sequence_length = len(unit_sequence)
|
|
sequence_vector[len(unit_sequence) + 1] = self.unit_to_id[self.stop_symbol]
|
|
else:
|
|
sequence_length = self.max_sequence_length - 1
|
|
|
|
for i in range(sequence_length):
|
|
curr_unit = unit_sequence[i]
|
|
if curr_unit in self.unit_to_id:
|
|
curr_id = self.unit_to_id[curr_unit]
|
|
else:
|
|
curr_id = self.unit_to_id[self.unknown_symbol]
|
|
sequence_vector[i + 1] = curr_id
|
|
return sequence_vector
|
|
|
|
|
|
class _SequencePostprocessing(BasePostprocessingModule):
|
|
def __init__(self, metadata: TrainingSetMetadataDict):
|
|
super().__init__()
|
|
self.max_sequence_length = int(metadata["max_sequence_length"])
|
|
self.idx2str = metadata["idx2str"]
|
|
self.unknown_symbol = UNKNOWN_SYMBOL
|
|
self.predictions_key = PREDICTIONS
|
|
self.probabilities_key = PROBABILITIES
|
|
self.probability_key = PROBABILITY
|
|
|
|
def forward(self, preds: dict[str, torch.Tensor], feature_name: str) -> FeaturePostProcessingOutputDict:
|
|
pred_predictions = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.predictions_key)
|
|
pred_probabilities = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.probabilities_key)
|
|
|
|
predictions: list[list[str]] = []
|
|
for sequence in pred_predictions:
|
|
sequence_predictions: list[str] = []
|
|
for i in range(self.max_sequence_length):
|
|
unit_id = int(sequence[i].item())
|
|
if unit_id < len(self.idx2str):
|
|
unit_prediction = self.idx2str[unit_id]
|
|
else:
|
|
unit_prediction = self.unknown_symbol
|
|
sequence_predictions.append(unit_prediction)
|
|
predictions.append(sequence_predictions)
|
|
|
|
probabilities, _ = torch.max(pred_probabilities, dim=-1)
|
|
probability = torch.sum(torch.log(probabilities.clamp(min=1e-10)), dim=-1)
|
|
|
|
return {
|
|
self.predictions_key: predictions,
|
|
self.probabilities_key: probabilities,
|
|
self.probability_key: probability,
|
|
}
|
|
|
|
|
|
class _SequencePredict(PredictModule):
|
|
def forward(self, inputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]:
|
|
logits = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.logits_key)
|
|
probabilities = torch.softmax(logits, -1)
|
|
predictions = torch.argmax(logits, -1)
|
|
|
|
# predictions: [batch_size, sequence_length]
|
|
# probabilities: [batch_size, sequence_length, vocab_size]
|
|
# logits: [batch_size, sequence_length, vocab_size]
|
|
return {self.predictions_key: predictions, self.probabilities_key: probabilities, self.logits_key: logits}
|
|
|
|
|
|
class SequenceFeatureMixin(FeaturePreprocessingMixin):
|
|
@staticmethod
|
|
def type():
|
|
return SEQUENCE
|
|
|
|
@staticmethod
|
|
def cast_column(column, backend):
|
|
return column.astype(str)
|
|
|
|
@staticmethod
|
|
def get_feature_meta(
|
|
config: ModelConfigDict,
|
|
column,
|
|
preprocessing_parameters: PreprocessingConfigDict,
|
|
backend,
|
|
is_input_feature: bool,
|
|
) -> FeatureMetadataDict:
|
|
vocabulary = create_vocabulary(
|
|
column,
|
|
preprocessing_parameters["tokenizer"],
|
|
lowercase=preprocessing_parameters["lowercase"],
|
|
num_most_frequent=preprocessing_parameters["most_common"],
|
|
vocab_file=preprocessing_parameters["vocab_file"],
|
|
unknown_symbol=preprocessing_parameters["unknown_symbol"],
|
|
padding_symbol=preprocessing_parameters["padding_symbol"],
|
|
ngram_size=preprocessing_parameters["ngram_size"],
|
|
processor=backend.df_engine,
|
|
)
|
|
logger.info(
|
|
f"Max length of feature '{column.name}': {vocabulary.max_sequence_length} (without start and stop symbols)"
|
|
)
|
|
|
|
# Use sequence_length if provided, otherwise use max length found in dataset.
|
|
if preprocessing_parameters["sequence_length"] is not None:
|
|
logger.info(
|
|
f"Setting max length to sequence_length={preprocessing_parameters['sequence_length']} provided in "
|
|
f"preprocessing parameters"
|
|
)
|
|
max_sequence_length = preprocessing_parameters["sequence_length"]
|
|
else:
|
|
max_sequence_length = vocabulary.max_sequence_length
|
|
logger.info(f"Setting max length using dataset: {max_sequence_length} (including start and stop symbols)")
|
|
|
|
# If max_sequence_length is None, then use the max length found in the dataset.
|
|
if (
|
|
preprocessing_parameters["max_sequence_length"] is not None
|
|
and preprocessing_parameters["max_sequence_length"] < max_sequence_length
|
|
):
|
|
logger.info(
|
|
f"Truncating max length with max_sequence_length={preprocessing_parameters['max_sequence_length']} "
|
|
f"from preprocessing parameters"
|
|
)
|
|
max_sequence_length = preprocessing_parameters["max_sequence_length"]
|
|
|
|
logger.info(f"Max sequence length is {max_sequence_length} for feature '{column.name}'")
|
|
return {
|
|
"idx2str": vocabulary.vocab,
|
|
"str2idx": vocabulary.str2idx,
|
|
"str2freq": vocabulary.str2freq,
|
|
"vocab_size": len(vocabulary.vocab),
|
|
"max_sequence_length": max_sequence_length,
|
|
}
|
|
|
|
@staticmethod
|
|
def feature_data(column, metadata, preprocessing_parameters: PreprocessingConfigDict, backend):
|
|
sequence_data = build_sequence_matrix(
|
|
sequences=column,
|
|
inverse_vocabulary=metadata["str2idx"],
|
|
tokenizer_type=preprocessing_parameters["tokenizer"],
|
|
length_limit=metadata["max_sequence_length"],
|
|
padding_symbol=preprocessing_parameters["padding_symbol"],
|
|
padding=preprocessing_parameters["padding"],
|
|
unknown_symbol=preprocessing_parameters["unknown_symbol"],
|
|
lowercase=preprocessing_parameters["lowercase"],
|
|
tokenizer_vocab_file=preprocessing_parameters["vocab_file"],
|
|
processor=backend.df_engine,
|
|
)
|
|
return sequence_data
|
|
|
|
@staticmethod
|
|
def add_feature_data(
|
|
feature_config,
|
|
input_df,
|
|
proc_df,
|
|
metadata,
|
|
preprocessing_parameters: PreprocessingConfigDict,
|
|
backend,
|
|
skip_save_processed_input,
|
|
):
|
|
sequence_data = SequenceInputFeature.feature_data(
|
|
input_df[feature_config[COLUMN]],
|
|
metadata[feature_config[NAME]],
|
|
preprocessing_parameters,
|
|
backend,
|
|
)
|
|
proc_df[feature_config[PROC_COLUMN]] = sequence_data
|
|
return proc_df
|
|
|
|
|
|
class SequenceInputFeature(SequenceFeatureMixin, InputFeature):
|
|
def __init__(self, input_feature_config: SequenceInputFeatureConfig, encoder_obj=None, **kwargs):
|
|
super().__init__(input_feature_config, **kwargs)
|
|
|
|
if encoder_obj:
|
|
self.encoder_obj = encoder_obj
|
|
else:
|
|
self.encoder_obj = self.initialize_encoder(input_feature_config.encoder)
|
|
|
|
def forward(self, inputs: torch.Tensor, mask=None):
|
|
if not isinstance(inputs, torch.Tensor):
|
|
raise TypeError(f"Sequence feature forward expects a torch.Tensor, got {type(inputs).__name__}.")
|
|
if len(inputs.shape) != 2:
|
|
raise ValueError(f"Sequence feature inputs must be 2D (batch x seq_len), got shape {tuple(inputs.shape)}.")
|
|
inputs_exp = inputs.type(torch.int32)
|
|
inputs_mask = torch.not_equal(inputs, SpecialSymbol.PADDING.value)
|
|
lengths = torch.sum(inputs_mask.type(torch.int32), dim=1)
|
|
encoder_output = self.encoder_obj(inputs_exp, mask=inputs_mask)
|
|
encoder_output[LENGTHS] = lengths
|
|
return encoder_output
|
|
|
|
@property
|
|
def input_dtype(self):
|
|
return torch.int32
|
|
|
|
@staticmethod
|
|
def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs):
|
|
feature_config.encoder.vocab = feature_metadata["idx2str"]
|
|
feature_config.encoder.vocab_size = len(feature_metadata["idx2str"])
|
|
feature_config.encoder.max_sequence_length = feature_metadata["max_sequence_length"]
|
|
|
|
@staticmethod
|
|
def get_schema_cls():
|
|
return SequenceInputFeatureConfig
|
|
|
|
@property
|
|
def input_shape(self) -> torch.Size:
|
|
return torch.Size([self.encoder_obj.config.max_sequence_length])
|
|
|
|
@property
|
|
def output_shape(self) -> torch.Size:
|
|
return self.encoder_obj.output_shape
|
|
|
|
@staticmethod
|
|
def create_preproc_module(metadata: TrainingSetMetadataDict) -> BasePreprocessingModule:
|
|
return _SequencePreprocessing(metadata)
|
|
|
|
|
|
class SequenceOutputFeature(SequenceFeatureMixin, OutputFeature):
|
|
def __init__(
|
|
self,
|
|
output_feature_config: SequenceOutputFeatureConfig | dict,
|
|
output_features: dict[str, OutputFeature],
|
|
**kwargs,
|
|
):
|
|
super().__init__(output_feature_config, output_features, **kwargs)
|
|
self.decoder_obj = self.initialize_decoder(output_feature_config.decoder)
|
|
self._setup_loss()
|
|
self._setup_metrics()
|
|
|
|
def logits(self, inputs: dict[str, torch.Tensor], target=None):
|
|
return self.decoder_obj(inputs, target=target)
|
|
|
|
def create_predict_module(self) -> PredictModule:
|
|
return _SequencePredict()
|
|
|
|
def get_prediction_set(self):
|
|
return self.decoder_obj.get_prediction_set()
|
|
|
|
@classmethod
|
|
def get_output_dtype(cls):
|
|
return torch.int32
|
|
|
|
@property
|
|
def input_shape(self) -> torch.Size:
|
|
# Dummy implementation.
|
|
return torch.Size([1])
|
|
|
|
@property
|
|
def output_shape(self) -> torch.Size:
|
|
return torch.Size([self.decoder_obj.config.max_sequence_length])
|
|
|
|
@staticmethod
|
|
def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs):
|
|
feature_config.decoder.vocab_size = feature_metadata["vocab_size"]
|
|
feature_config.decoder.max_sequence_length = feature_metadata["max_sequence_length"]
|
|
if isinstance(feature_config.loss.class_weights, (list, tuple)):
|
|
if len(feature_config.loss.class_weights) != feature_config.decoder.vocab_size:
|
|
raise ValueError(
|
|
f"The length of class_weights ({len(feature_config.loss.class_weights)}) is not compatible with "
|
|
f"the number of classes ({feature_config.decoder.vocab_size}) for feature {feature_config.column}. "
|
|
"Check the metadata JSON file to see the classes "
|
|
"and their order and consider there needs to be a weight "
|
|
"for the <UNK> and <PAD> class too."
|
|
)
|
|
|
|
if isinstance(feature_config.loss.class_weights, dict):
|
|
if feature_metadata["str2idx"].keys() != feature_config.loss.class_weights.keys():
|
|
raise ValueError(
|
|
f"The class_weights keys ({feature_config.loss.class_weights.keys()}) are not compatible with "
|
|
f"the classes ({feature_metadata['str2idx'].keys()}) of feature {feature_config.column}. "
|
|
"Check the metadata JSON file to see the classes "
|
|
"and consider there needs to be a weight "
|
|
"for the <UNK> class too."
|
|
)
|
|
else:
|
|
class_weights = feature_config.loss.class_weights
|
|
idx2str = feature_metadata["idx2str"]
|
|
class_weights_list = [class_weights[s] for s in idx2str]
|
|
feature_config.loss.class_weights = class_weights_list
|
|
|
|
if feature_config.loss.class_similarities_temperature > 0:
|
|
if feature_config.loss.class_similarities is not None:
|
|
similarities = feature_config.loss.class_similarities
|
|
temperature = feature_config.loss.class_similarities_temperature
|
|
|
|
curr_row = 0
|
|
first_row_length = 0
|
|
is_first_row = True
|
|
for row in similarities:
|
|
if is_first_row:
|
|
first_row_length = len(row)
|
|
is_first_row = False
|
|
curr_row += 1
|
|
else:
|
|
curr_row_length = len(row)
|
|
if curr_row_length != first_row_length:
|
|
raise ValueError(
|
|
f"The length of row {curr_row} of the class_similarities "
|
|
f"of {feature_config.column} is {curr_row_length}, different from the length of "
|
|
f"the first row {first_row_length}. All rows must have "
|
|
"the same length."
|
|
)
|
|
else:
|
|
curr_row += 1
|
|
all_rows_length = first_row_length
|
|
|
|
if all_rows_length != len(similarities):
|
|
raise ValueError(
|
|
f"The class_similarities matrix of {feature_config.column} has "
|
|
f"{len(similarities)} rows and {all_rows_length} columns, "
|
|
"their number must be identical."
|
|
)
|
|
|
|
if all_rows_length != feature_config.decoder.vocab_size:
|
|
raise ValueError(
|
|
f"The size of the class_similarities matrix of {feature_config.column} is "
|
|
f"{all_rows_length}, different from the number of classes "
|
|
f"({feature_config.decoder.vocab_size}). Check the metadata JSON file to see the classes "
|
|
"and their order and "
|
|
"consider <UNK> and <PAD> class too."
|
|
)
|
|
|
|
similarities = np.array(similarities, dtype=np.float32)
|
|
for i in range(len(similarities)):
|
|
similarities[i, :] = softmax(similarities[i, :], temperature=temperature)
|
|
feature_config.loss.class_similarities = similarities
|
|
else:
|
|
raise ValueError(
|
|
"class_similarities_temperature > 0, "
|
|
"but no class_similarities are provided "
|
|
f"for feature {feature_config.column}"
|
|
)
|
|
|
|
@staticmethod
|
|
def calculate_overall_stats(predictions, targets, train_set_metadata):
|
|
# TODO(Justin): Add a confusion matrix, see
|
|
# https://github.com/ludwig-ai/ludwig/blob/tf-legacy/ludwig/features/sequence_feature.py#L411
|
|
return {}
|
|
|
|
def postprocess_predictions(
|
|
self,
|
|
result,
|
|
metadata,
|
|
):
|
|
predictions_col = f"{self.feature_name}_{PREDICTIONS}"
|
|
lengths_col = f"{self.feature_name}_{LENGTHS}"
|
|
if predictions_col in result:
|
|
if "idx2str" in metadata:
|
|
|
|
def idx2str(row):
|
|
pred = row[predictions_col]
|
|
length = metadata["max_sequence_length"]
|
|
return [
|
|
metadata["idx2str"][token] if token < len(metadata["idx2str"]) else UNKNOWN_SYMBOL
|
|
for token in [pred[i] for i in range(length)]
|
|
]
|
|
|
|
result[predictions_col] = result.apply(idx2str, axis=1)
|
|
|
|
last_preds_col = f"{self.feature_name}_{LAST_PREDICTIONS}"
|
|
if last_preds_col in result:
|
|
if "idx2str" in metadata:
|
|
|
|
def last_idx2str(last_pred):
|
|
if last_pred < len(metadata["idx2str"]):
|
|
return metadata["idx2str"][last_pred]
|
|
return UNKNOWN_SYMBOL
|
|
|
|
result[last_preds_col] = result[last_preds_col].map(last_idx2str)
|
|
|
|
probs_col = f"{self.feature_name}_{PROBABILITIES}"
|
|
prob_col = f"{self.feature_name}_{PROBABILITY}"
|
|
if probs_col in result:
|
|
# currently does not return full probabilties because usually it is huge:
|
|
# dataset x length x classes
|
|
# TODO: add a mechanism for letting the user decide to save it
|
|
result[probs_col] = result[probs_col].map(compute_token_probabilities)
|
|
result[prob_col] = result[probs_col].map(
|
|
partial(
|
|
compute_sequence_probability,
|
|
max_sequence_length=metadata["max_sequence_length"],
|
|
return_log_prob=True,
|
|
),
|
|
)
|
|
|
|
if lengths_col in result:
|
|
del result[lengths_col]
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module:
|
|
return _SequencePostprocessing(metadata)
|
|
|
|
@staticmethod
|
|
def get_schema_cls():
|
|
return SequenceOutputFeatureConfig
|