2025, Oct 07 23:22

Как устранить ошибку different structures в многовыходной классификации Keras

Ошибка different structures в Keras: как согласовать y_true и y_pred, оформить метки как словарь по именам выходов и задать SparseCategoricalCrossentropy.

Обучая многовыходной классификатор изображений в Keras, легко наткнуться на неожиданный конфликт типов: модель возвращает список выходов, а датасет — один вектор на образец. В итоге возникает ошибка несоответствия структур, которая останавливает обучение. Ниже — понятное объяснение, почему это происходит, и как оформить метки так, чтобы Keras сопоставлял их с каждой выходной «головой».

Симптом

При запуске обучения процесс прерывается сообщением:

ValueError: y_true and y_pred have different structures.
y_true: *
y_pred: ['*', '*', '*', '*']

Постановка задачи: минимальный пример

Датасет возвращает изображение и четырёхэлементный вектор категориальных целей на каждое изображение. У модели четыре головы, для каждой применяется SparseCategoricalCrossentropy.

def fetch_targets(p):
    p = p.numpy().decode("utf-8")
    k = os.path.basename(p)[:9]
    if k not in target_map:
        print("Missing key:", k)
        raise ValueError("Missing label key.")
    return tf.convert_to_tensor(target_map[k], dtype=tf.uint8)

def load_frame(p):
    raw = tf.io.read_file(p)
    pic = tf.io.decode_jpeg(raw, channels=3)
    return tf.image.resize_with_crop_or_pad(pic, 360, 360)

def make_sample(file_p):
    y = tf.py_function(func=fetch_targets, inp=[file_p], Tout=tf.uint8)
    y.set_shape([4])
    x = tf.py_function(func=load_frame, inp=[file_p], Tout=tf.uint8)
    x.set_shape([360, 360, 3])
    return x, y

# Пример содержимого target_map
# 'Img_00001': [0, 1, 0, 1], 'Img_00002': [2, 0, 4, 1], 'Img_00003': [2, 0, 1, 0],
# 'Img_00004': [4, 1, 2, 1], 'Img_00005': [3, 1, 3, 1], 'Img_00006': [1, 1, 5, 1]

split_count = int(file_list_ds.cardinality().numpy() * 0.2)

train_ds = file_list_ds \
  .skip(split_count) \
  .map(make_sample, num_parallel_calls=tf.data.AUTOTUNE) \
  .cache() \
  .batch(100) \
  .prefetch(buffer_size=tf.data.AUTOTUNE)

val_ds = file_list_ds \
  .take(split_count) \
  .map(make_sample, num_parallel_calls=tf.data.AUTOTUNE) \
  .cache() \
  .batch(100) \
  .prefetch(buffer_size=tf.data.AUTOTUNE)

input_tensor = tf.keras.layers.Input(shape=(360, 360, 3))

feats = tf.keras.layers.Rescaling(1./255)(input_tensor)
feats = tf.keras.layers.Conv2D(32, (3, 3), activation='relu', padding='same')(feats)
feats = tf.keras.layers.MaxPooling2D((2, 2))(feats)
feats = tf.keras.layers.Conv2D(64, (3, 3), activation='relu', padding='same')(feats)
feats = tf.keras.layers.MaxPooling2D((2, 2))(feats)
feats = tf.keras.layers.Conv2D(128, (3, 3), activation='relu', padding='same')(feats)
feats = tf.keras.layers.Flatten()(feats)
feats = tf.keras.layers.Dense(128, activation='relu')(feats)

head_label = tf.keras.layers.Dense(len(label_classes))(feats)
head_cellshape = tf.keras.layers.Dense(len(cellshape_classes))(feats)
head_nucleusshape = tf.keras.layers.Dense(len(nucleusshape_classes))(feats)
head_cytovacuole = tf.keras.layers.Dense(len(cytovacuole_classes))(feats)

net = tf.keras.Model(
    inputs=input_tensor,
    outputs=[head_label, head_cellshape, head_nucleusshape, head_cytovacuole]
)

net.compile(
  optimizer=tf.keras.optimizers.Adam(),
  loss={
    "head_label": tf.keras.losses.SparseCategoricalCrossentropy(),
    "head_cellshape": tf.keras.losses.SparseCategoricalCrossentropy(),
    "head_nucleusshape": tf.keras.losses.SparseCategoricalCrossentropy(),
    "head_cytovacuole": tf.keras.losses.SparseCategoricalCrossentropy()
  },
  metrics={
    "head_label": ["sparse_categorical_accuracy"],
    "head_cellshape": ["sparse_categorical_accuracy"],
    "head_nucleusshape": ["sparse_categorical_accuracy"],
    "head_cytovacuole": ["sparse_categorical_accuracy"]
  }
)

history = net.fit(
  train_ds,
  validation_data=val_ds,
  epochs=10,
  batch_size=100,
  validation_steps=1
)

Причина ошибки

Модель выдаёт четыре выхода — по одному на каждую голову классификации. Keras ожидает, что структура целевых значений зеркально совпадёт со структурой предсказаний. Вместо четырёх отдельных целей (по одной на голову) датасет возвращает один вектор из четырёх целых. Из‑за этого возникает ошибка “different structures”, потому что Keras не может связать каждую функцию потерь с соответствующей частью целевого значения.

Решение: передавать словарь меток с ключами — именами выходов

Каждый образец должен возвращать отображение от имени выхода к индексу класса. Так фреймворк направит соответствующую часть y_true в нужную голову и функцию потерь. Цель для одного образца должна выглядеть так:

example_target = {
    "head_label": tf.Tensor([0]),
    "head_cellshape": tf.Tensor([1]),
    "head_nucleusshape": tf.Tensor([0]),
    "head_cytovacuole": tf.Tensor([1]),
}

А для батча размером 5:

example_batch = {
    "head_label": tf.Tensor([0, 2, 3, 1, 0]),
    "head_cellshape": tf.Tensor([3, 2, 3, 2, 0]),
    "head_nucleusshape": tf.Tensor([2, 2, 3, 4, 1]),
    "head_cytovacuole": tf.Tensor([1, 2, 3, 1, 1]),
}

Единственное изменение в конвейере ввода — превратить четырёхэлементный вектор в словарь с ключами, совпадающими с именами выходов модели.

Рабочий конвейер с исправленными метками

Ниже — обновлённая функция формирования сэмпла; остальной пайплайн без изменений. Блоки модели и компиляции те же, что в постановке задачи; отличается только структура меток.

def fetch_targets(p):
    p = p.numpy().decode("utf-8")
    k = os.path.basename(p)[:9]
    if k not in target_map:
        print("Missing key:", k)
        raise ValueError("Missing label key.")
    return tf.convert_to_tensor(target_map[k], dtype=tf.uint8)

def load_frame(p):
    raw = tf.io.read_file(p)
    pic = tf.io.decode_jpeg(raw, channels=3)
    return tf.image.resize_with_crop_or_pad(pic, 360, 360)

def make_sample(file_p):
    y_vec = tf.py_function(func=fetch_targets, inp=[file_p], Tout=tf.uint8)
    y_vec.set_shape([4])

    x = tf.py_function(func=load_frame, inp=[file_p], Tout=tf.uint8)
    x.set_shape([360, 360, 3])

    y_dict = {
        "head_label": y_vec[0],
        "head_cellshape": y_vec[1],
        "head_nucleusshape": y_vec[2],
        "head_cytovacuole": y_vec[3],
    }
    return x, y_dict

split_count = int(file_list_ds.cardinality().numpy() * 0.2)

train_ds = file_list_ds \
  .skip(split_count) \
  .map(make_sample, num_parallel_calls=tf.data.AUTOTUNE) \
  .cache() \
  .batch(100) \
  .prefetch(buffer_size=tf.data.AUTOTUNE)

val_ds = file_list_ds \
  .take(split_count) \
  .map(make_sample, num_parallel_calls=tf.data.AUTOTUNE) \
  .cache() \
  .batch(100) \
  .prefetch(buffer_size=tf.data.AUTOTUNE)

input_tensor = tf.keras.layers.Input(shape=(360, 360, 3))

feats = tf.keras.layers.Rescaling(1./255)(input_tensor)
feats = tf.keras.layers.Conv2D(32, (3, 3), activation='relu', padding='same')(feats)
feats = tf.keras.layers.MaxPooling2D((2, 2))(feats)
feats = tf.keras.layers.Conv2D(64, (3, 3), activation='relu', padding='same')(feats)
feats = tf.keras.layers.MaxPooling2D((2, 2))(feats)
feats = tf.keras.layers.Conv2D(128, (3, 3), activation='relu', padding='same')(feats)
feats = tf.keras.layers.Flatten()(feats)
feats = tf.keras.layers.Dense(128, activation='relu')(feats)

head_label = tf.keras.layers.Dense(len(label_classes))(feats)
head_cellshape = tf.keras.layers.Dense(len(cellshape_classes))(feats)
head_nucleusshape = tf.keras.layers.Dense(len(nucleusshape_classes))(feats)
head_cytovacuole = tf.keras.layers.Dense(len(cytovacuole_classes))(feats)

net = tf.keras.Model(
    inputs=input_tensor,
    outputs=[head_label, head_cellshape, head_nucleusshape, head_cytovacuole]
)

net.compile(
  optimizer=tf.keras.optimizers.Adam(),
  loss={
    "head_label": tf.keras.losses.SparseCategoricalCrossentropy(),
    "head_cellshape": tf.keras.losses.SparseCategoricalCrossentropy(),
    "head_nucleusshape": tf.keras.losses.SparseCategoricalCrossentropy(),
    "head_cytovacuole": tf.keras.losses.SparseCategoricalCrossentropy()
  },
  metrics={
    "head_label": ["sparse_categorical_accuracy"],
    "head_cellshape": ["sparse_categorical_accuracy"],
    "head_nucleusshape": ["sparse_categorical_accuracy"],
    "head_cytovacuole": ["sparse_categorical_accuracy"]
  }
)

history = net.fit(
  train_ds,
  validation_data=val_ds,
  epochs=10,
  batch_size=100,
  validation_steps=1
)

Почему это важно

Многоголовые модели опираются на взаимно‑однозначное соответствие между выходами, функциями потерь и целями. Если в датасете все цели объединены в один вектор, фреймворк не может однозначно понять, какая часть относится к какой голове. Согласованность структур предотвращает скрытые ошибки обучения, оставляет метрики привязанными к нужным головам и делает причины сбоев сразу понятными.

Главные выводы

Синхронизируйте структуру выходов модели со структурой меток, которые выдаёт датасет. Для многовыходной классификации с отдельными функциями SparseCategoricalCrossentropy передавайте словарь целей, у которого ключи совпадают с именами голов модели. Это небольшое изменение устраняет несоответствие структур и позволяет Keras без догадок сопоставить каждое y_true с соответствующим y_pred и функцией потерь.

Статья основана на вопросе на StackOverflow от Fish4203 и ответе hvater.