Как научить ИИ превращать дизайн-макеты в HTML и CSS. Часть 3

NOP
NOP::Nuances of Programming
10 min readJul 16, 2018

Перевод статьи Emil Wallner: How you can train an AI to convert your design mockups into HTML and CSS

Предыдущие части: Часть 1, Часть 2

В первых двух частях статьи мы поговорили о базовых принципах обучения нейронных сетей генерированию разметки на основании скриншотов, а также потренировались на Hello World и HTML-моделях.

Далее создадим итоговую версию Bootstrap.

Версия Bootstrap

В последней версии мы будем использовать набор данных со сгенерированных bootstrap сайтов, руководствуясь статьей о pix2code. Bootstrap Твиттера позволяет объединить HTML с CSS и сократить объем словаря.

Это очень пригодится при создании разметки на основании новых для алгоритма скриншотов. Также разберемся в процедуре «накапливания знания» о скриншоте и разметке.

Вместо стандартного обучения на разметке bootstrap будем использовать 17 упрощенных токенов, которые затем переведем в HTML и CSS. Набор данных включает в себя 1500 текстовых скриншотов и 250 проверочных изображений. Для каждого скриншота образуется порядка 65 токенов, в результате чего получается 96 925 тренировочных примеров.

Оптимизация алгоритма из pix2code документа позволяет модели предсказывать веб-компоненты с 97%-ной точностью (алгоритм BLEU с 4-х N-гранным жадным поиском. Дальше еще поговорим о нем).

End-to-end подход (сквозной)

Извлечение признаков из предобученных алгоритмов хорошо подходит для моделей с захватом изображений. Но после пары экспериментов я понял, что для этих целей лучше использовать сквозной метод pix2code. Предобученные модели не тренировались на веб-данных и изначально настроены для разных классификаций.

В данном примере мы заменим признаки уже изученных изображений небольшой сверточной сетью. И предельное объединение мы будем использовать не для повышения интенсивности потока информации, а для увеличения величины шага. Это позволит сохранить расположение и цвет элементов во фронтенде.

Здесь нам подойдут сразу две базовые модели: сверточная (CNN) и рекуррентная (RNN) нейронная сеть. Последняя наиболее часто используется в долгой кратковременной памяти (LSTM), поэтому я выбираю именно ее.

Существует множество подробных уроков по сверточным сетям (небольшой пример). Но в этой статье я буду говорить именно о LSTM.

Определение временных шагов в LSTM

Пожалуй, самым трудным разделом LSTM являются временные шаги. Нейронную сеть Vanilla можно представить в виде двух таких шагов. Если сеть видит “Hello”, то она предсказывает “World”. Но предугадать большее количество шагов ей будет достаточно трудно. В примере ниже входное значение имеет целых четыре временных шага для каждого слова.

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

Входные и выходные весовые значения объединяются и добавляются с помощью активации. Это выходное значение для данного временного шага. А поскольку весовые коэффициенты используются по нескольку раз, то они берут информацию из разных входных значений и создают свое знание о последовательности.

Ниже приведена упрощенная версия процессов в LSTM для каждого временного шага.

Чтобы понять базовую логику всего этого, я советую создать рекуррентную нейронную сеть с нуля, используя отличное руководство Эндрю Траска.

Определение уровней в слоях LSTM

Количество уровней в каждом слое LSTM определяет ее способность к запоминанию. Оно также зависит от размера каждого выходного признака. Напомню: признак — это длинный список цифр, которые используются для передачи информации между слоями.

Каждый уровень в слое LSTM учится отслеживать различные части синтаксиса. Ниже приведена визуализация уровня, которые отслеживает информацию в строке div. Это упрощенная разметка — ее мы используем для обучения модели с bootstrap.

Каждый уровень LSTM поддерживает состояние ячейки (т.е. память). Для изменения данного состояния используются активации и весовые значения. Это позволяет LSTM-слоям «фильтровать» информацию для каждого входного значения на нужную и ненужную.

Помимо прохождения через выходной признак для каждого входа ведется передача состояний ячейки — по одному значению для каждого уровня в LSTM. Более подробно о способах взаимодействия компонентов внутри LSTM можно ознакомиться в статье Кола, Реализации NumPy от Джаясири, в лекции Карфая и рецензии к ней.

dir_name = 'resources/eval_light/'
# Чтение файла и возвращение строки
def load_doc(filename):
file = open(filename, 'r')
text = file.read()
file.close()
return text
def load_data(data_dir):
text = []
images = []
# Загрузка всех файлов и их сортировка
all_filenames = listdir(data_dir)
all_filenames.sort()
for filename in (all_filenames):
if filename[-3:] == "npz":
# загружает уже подготовленные изображения в массивы
image = np.load(data_dir+filename)
images.append(image['features'])
else:
# загружает токены boostrap, помещает их в начальный и конечный тег
syntax = '<START> ' + load_doc(data_dir+filename) + ' <END>'
# отделяет все слова единичным пробелом
syntax = ' '.join(syntax.split())
# добавляет пробел после каждой точки
syntax = syntax.replace(',', ' ,')
text.append(syntax)
images = np.array(images, dtype=float)
return images, text
train_features, texts = load_data(dir_name)
# Инициирование функции для создания словаря
tokenizer = Tokenizer(filters='', split=" ", lower=False)
# Создание словаря
tokenizer.fit_on_texts([load_doc('bootstrap.vocab')])
# Резервирование места в словаре для пустого слова
vocab_size = len(tokenizer.word_index) + 1
# Преобразование входных предложений в индексы словаря
train_sequences = tokenizer.texts_to_sequences(texts)
# Самый длинный набор boostrap-токенов
max_sequence = max(len(s) for s in train_sequences)
# Определение количества токенов в каждом входном предложении
max_length = 48
def preprocess_data(sequences, features):
X, y, image_data = list(), list(), list()
for img_no, seq in enumerate(sequences):
for i in range(1, len(seq)):
# добавляет предложение до текущего счетчика (i) и включает это значение для выхода
in_seq, out_seq = seq[:i], seq[i]
# заполняет все входные токены предложений в max_sequence
in_seq = pad_sequences([in_seq], maxlen=max_sequence)[0]
# переводит выход на прямое кодирование
out_seq = to_categorical([out_seq], num_classes=vocab_size)[0]
# добавляет соответствующую картинку в файл boostrap-токена
image_data.append(features[img_no])
# разбивает входное предложение на 48 токенов и добавляет его
X.append(in_seq[-48:])
y.append(out_seq)
return np.array(X), np.array(y), np.array(image_data)
X, y, image_data = preprocess_data(train_sequences, train_features)
# Создание энкодера
image_model = Sequential()
image_model.add(Conv2D(16, (3, 3), padding='valid', activation='relu', input_shape=(256, 256, 3,)))
image_model.add(Conv2D(16, (3,3), activation='relu', padding='same', strides=2))
image_model.add(Conv2D(32, (3,3), activation='relu', padding='same'))
image_model.add(Conv2D(32, (3,3), activation='relu', padding='same', strides=2))
image_model.add(Conv2D(64, (3,3), activation='relu', padding='same'))
image_model.add(Conv2D(64, (3,3), activation='relu', padding='same', strides=2))
image_model.add(Conv2D(128, (3,3), activation='relu', padding='same'))
image_model.add(Flatten())
image_model.add(Dense(1024, activation='relu'))
image_model.add(Dropout(0.3))
image_model.add(Dense(1024, activation='relu'))
image_model.add(Dropout(0.3))
image_model.add(RepeatVector(max_length))
visual_input = Input(shape=(256, 256, 3,))
encoded_image = image_model(visual_input)
language_input = Input(shape=(max_length,))
language_model = Embedding(vocab_size, 50, input_length=max_length, mask_zero=True)(language_input)
language_model = LSTM(128, return_sequences=True)(language_model)
language_model = LSTM(128, return_sequences=True)(language_model)
# Создание декодера
decoder = concatenate([encoded_image, language_model])
decoder = LSTM(512, return_sequences=True)(decoder)
decoder = LSTM(512, return_sequences=False)(decoder)
decoder = Dense(vocab_size, activation='softmax')(decoder)
# Компиляция модели
model = Model(inputs=[visual_input, language_input], outputs=decoder)
optimizer = RMSprop(lr=0.0001, clipvalue=1.0)
model.compile(loss='categorical_crossentropy', optimizer=optimizer)
# Сохранение модели для каждой второй эпохи
filepath="org-weights-epoch-{epoch:04d}--val_loss-{val_loss:.4f}--loss-{loss:.4f}.hdf5"
checkpoint = ModelCheckpoint(filepath, monitor='val_loss', verbose=1, save_weights_only=True, period=2)
callbacks_list = [checkpoint]
# Обучение модели
model.fit([image_data, X], y, batch_size=64, shuffle=False, validation_split=0.1, callbacks=callbacks_list, verbose=1, epochs=50)

Точность тестирования

Не так просто найти подходящий способ для оценки точности информации. Допустим, вы сравниваете слова по порядку. Если предсказанное слово нарушает последовательность, то точность такого тестирования равна нулю. Если же в предсказании забывается одно слово из правильной последовательности, то точность теста — 99/100.

Я предпочитаю пользоваться оценками BLEU — самой популярной методики для оценки моделей в машинном переводе и захвате изображений. BLEU разбивает предложение на четыре N-граммы, т.е. последовательности в 1–4 слова. В предсказании ниже «кот» должен был быть «кодом».

Для получения результата умножаем каждый балл на 25%, (4/5) * 0.25 + (2/4) * 0.25 + (1/3) * 0.25 + (0/2) * 0.25 = 0.2 + 0.125 + 0.083 + 0 = 0.408 . Затем эта сумма умножается на штраф за длину предложения. А раз у нас в примере выбрана правильная длина предложения, то мы сразу же получаем итоговый результат.

Для усложнения процесса вы можете увеличить количество N-грамм. Четырех N-граммная модель больше всего похожа на «человеческий» перевод. Я бы посоветовал пробежаться по нескольким примерам со следующим кодом и почитать Википедию.

# Создание функции для чтения файла и возвращения его содержимого
def load_doc(filename):
file = open(filename, 'r')
text = file.read()
file.close()
return text
def load_data(data_dir):
text = []
images = []
files_in_folder = os.listdir(data_dir)
files_in_folder.sort()
for filename in tqdm(files_in_folder):
# Добавление изображения
if filename[-3:] == "npz":
image = np.load(data_dir+filename)
images.append(image['features'])
else:
# добавление текста, заключение его в начальный и конечный теги
syntax = '<START> ' + load_doc(data_dir+filename) + ' <END>'
# разделение слов через пробел
syntax = ' '.join(syntax.split())
# добавление пробела между каждой точкой
syntax = syntax.replace(',', ' ,')
text.append(syntax)
images = np.array(images, dtype=float)
return images, text
# Инициирование функции для создания словаря
tokenizer = Tokenizer(filters='', split=" ", lower=False)
# Создание упорядоченного словаря
tokenizer.fit_on_texts([load_doc('bootstrap.vocab')])
dir_name = '../../../../eval/'
train_features, texts = load_data(dir_name)
# Загрузка модели и весовых значений
json_file = open('../../../../model.json', 'r')
loaded_model_json = json_file.read()
json_file.close()
loaded_model = model_from_json(loaded_model_json)
# Загрузка весовых значений в новую модель
loaded_model.load_weights("../../../../weights.hdf5")
print("Loaded model from disk")
# Преобразование целого числа в слово
def word_for_id(integer, tokenizer):
for word, index in tokenizer.word_index.items():
if index == integer:
return word
return None
print(word_for_id(17, tokenizer))
# Создание описания для изображения
def generate_desc(model, tokenizer, photo, max_length):
photo = np.array([photo])
# начало процесса создания
in_text = '<START> '
# пошаговое выполнение по всей длине последовательности
print('\nPrediction---->\n\n<START> ', end='')
for i in range(150):
# перевод входной последовательности в целые числа
sequence = tokenizer.texts_to_sequences([in_text])[0]
# заполнение входа
sequence = pad_sequences([sequence], maxlen=max_length)
# предсказание следующего слова
yhat = loaded_model.predict([photo, sequence], verbose=0)
# преобразование вероятности в целое число
yhat = argmax(yhat)
# преобразование целого числа в слово
word = word_for_id(yhat, tokenizer)
# остановка, если преобразование слова невозможно
if word is None:
break
# добавление на вход при генерации следующего слова
in_text += word + ' '
# остановка при предсказании окончания последовательности
print(word + ' ', end='')
if word == '<END>':
break
return in_text
max_length = 48
# Оценка способности(навыка) модели
def evaluate_model(model, descriptions, photos, tokenizer, max_length):
actual, predicted = list(), list()
# пройтись по блоку
for i in range(len(texts)):
yhat = generate_desc(model, tokenizer, photos[i], max_length)
# сохранить фактическое и предсказанное
print('\n\nReal---->\n\n' + texts[i])
actual.append([texts[i].split()])
predicted.append(yhat.split())
# рассчитать оценку BLEU
bleu = corpus_bleu(actual, predicted)
return bleu, actual, predicted
bleu, actual, predicted = evaluate_model(loaded_model, texts, train_features, tokenizer, max_length)
#Компиляция токенов в HTML/CSS
dsl_path = "compiler/assets/web-dsl-mapping.json"
compiler = Compiler(dsl_path)
compiled_website = compiler.compile(predicted[0], 'index.html')
print(compiled_website )
print(bleu)

Результат

Ссылки на примеры:

· Сгенерированный сайт 1Оригинал 1

· Сгенерированный сайт 2Оригинал 2

· Сгенерированный сайт 3Оригинал 3

· Сгенерированный сайт 4Оригинал 4

· Сгенерированный сайт 5Оригинал 5

Допущенные ошибки:

· Разбирался в слабых сторонах одного алгоритма вместо тестирования случайных моделей. Поначалу я старался научить сеть внимательности с помощью разных примочек (батч-нормализация, двунаправленные сети и т.д.). При сравнении тестовых данных и предсказаний стало ясно, что модель не могла с высокой точностью предугадывать цвет и расположение элемента. Так я обнаружил слабое место сверточных нейронных сетей. Это натолкнуло меня на мысль о том, чтобы заменить предельное объединение значений увеличением шага. Тогда потери при проверке сократились с 0,12 до 0,02. Параллельно выросла оценка BLUE с 85% до 97%.

· Используйте только релевантные предобученные модели. Мне казалось, что при небольшом наборе данных предобученная на изображениях модель покажет лучшие результаты. Однако на практике выяснилось, что модели end-to-end учатся намного дольше и тратят куда больше памяти. Тем не менее, их результаты на 30% точнее.

· При запуске модели на удаленном сервере готовьтесь к небольшим расхождениям. На Маке файлы читаются в алфавитном порядке. Но на сервере все расположено хаотично. Это и создает расхождения данных между скриншотом и кодом. Достоверность, конечно же, присутствует, однако при проверке результат оказывается на 50% хуже, чем при заданном порядке показа/запоминания.

· Разберитесь в функциях библиотек. Зарезервируйте в словаре место для пустого токена. Как-то я забыл это сделать, и один из токенов не попал в словарь. Обнаружилось все только после многократной проверки выходного значения — система попросту не предсказала «единичный» токен. При проверке словаря оказалось, что такого токена там даже не было. Важный нюанс: при обучении и тестировании используйте одинаковый порядок сортировки.

· Для экспериментальных целей выбирайте облегченные модели. Если взять управляемые рекуррентные нейроны (GRU) вместо LSTM, то цикл эпохи сократится на 30%. Причем, какие-либо значимые изменения в производительности отмечаться не будут.

Следующие шаги

Фронтенд-разработка — идеальная среда для использования глубокого обучения. В нейронных сетях легко сгенерировать данные, а существующие алгоритмы обучения способы преобразовать почти любую логику.

Большой интерес вызывает обучение вниманию LSTM. Оно не просто улучшает достоверность результатов, но и наглядно показывает, где именно сверточная нейронная сеть уделяла максимум внимания при генерировании разметки.

Помимо прочего, внимание — это связующий ключ между разметкой, таблицами стилей, скриптами и, в дальнейшем, бэкендом. Уровни внимания отслеживают переменные и позволяют сети общаться на разных языках программирования.

Но в ближайшем будущем главный акцент будет стоять на создании масштабируемых способов синтезирования данных. Тогда мы сможем последовательно добавлять шрифты, цвета, слова и анимацию.

Пока что наибольший КПД получается при создании эскизов и превращении их в шаблонные приложения. Года через два мы сможем отрисовывать приложение на бумаге и за секунды получать уже готовый фронтенд. Уже сейчас есть два рабочих прототипа, созданных дизайнерами Airbnb и Uizard.

Ниже приведены некоторые базовые эксперименты по работе с сейтью.

Эксперименты

Начало

· Запуск всех моделей

· Проверка различных гиперпараметров

· Тестирование всевозможных архитектур сверточных сетей

· Добавление двунаправленных LSTM моделей

· Внедрение модели с отличающимся набором данных. (Можете легко установить этот набор данных во FloydHub через метку — data emilwallner/datasets/100k-html:data)

Продолжение

  • Создание работающего веб-генератора/приложения для случайных чисел с соответствующим синтаксисом.
  • Трансформация данных с эскиза до модели приложения. Авто-преобразование скриншотов (веб/приложения) в эскизы и использование генеративно-состязательной сети (GAN) для создания множества значений.
  • Использование уровня внимания для визуализации работы сети над изображением каждого предсказания. Например, как тут.
  • Создание фреймворка для модульного подхода. Допустим, сделать модели энкодеров для шрифтов (по одному на цвет) и шаблона, а затем соединить их в одном декодере. Отлично подойдут устойчивые (сплошные) признаки изображений.
  • Скормить сети простые HTML-компоненты и научить ее генерировать анимацию в CSS. Самый смак — добавить сюда подход с научением вниманию и визуализацией работы сети над всеми входными значениями.

--

--