29  Приложения Shiny

29.1 Создание директории и файла приложения

  1. File -> New Project -> New Directory -> Shiny Application

  2. Файл app.R содержит скрипт, который

  • определяет пользовательский интерфейс - страницу html, с которой будет взаимодействовать пользователь
  • формирует поведение приложения путем определения функции server
  • вызывает функцию shiny(ui, server) для сборки и запуска приложения

Запустить приложение можно кнопкой Run App.

На заметку

При запущенном приложении оболочка R переходит в состояние занятости: командная строка не видна, а на панели инструментов в консоли показывается иконка с символом остановки.

29.2 Элементы пользовательского интерфейса

29.2.1 Макет и заголовки

В созданном автоматически файле вы видите следующее.

ui <- fluidPage(

    # Application title
    titlePanel("Old Faithful Geyser Data"),

    # Sidebar with a slider input for number of bins 
    sidebarLayout(
        sidebarPanel(
            sliderInput("bins",
                        "Number of bins:",
                        min = 1,
                        max = 50,
                        value = 30)
        ),

        # Show a plot of the generated distribution
        mainPanel(
           plotOutput("distPlot")
        )
    )
)

Здесь:

  • fluidPage() - функция разметки, отвечающая за визуальную структуру приложения. Внутрь кладём всё, что хотим увидеть на экране. Обычно это какой-то Input, с которым взаимодействует пользователь, и какой-то Output.

  • titlePanel() отвечает за заголовок.

  • sidebarLayout(...) делит экран на две основные части: узкая панель с элементами управления (слева) и главная панель.

  • sidebarPanel(...) отвечает за боковую панель. В ней мы видимsliderInput() - это ползунок.

  • mainPanel(...) - это главная, большая панель. В ней будет что-то отображаться. Например, график: plotOutput().

Задание
  • Исправьте заголовок на "📰 Классификатор новостей". Так будет называться наше приложение.
  • Добавьте заголовок боковой панели, используя теги: tags$h4("Вставьте или напечатайте новость:"). Теги Shiny соответствуют тегам html.
  • Добавьте заголовок главной панели tags$h3("Результат классификации). С базовыми тегами писать tags$ перед именем тега не обязательно.
  • Попробуйте теги для форматирования (например, выделения курсивом или полужирным) внутри заголовков.
  • Не забывайте про запятые между функциями! (RStudio будет напоминать).
  • Измените ширину боковой панели. Посмотрите документацию к функции sidebarLayout().

Запустите приложение еще раз и посмотрите, что получилось.

29.2.2 Элементы ввода

Полный список элементов ввода доступен по ссылке.

Небольшие фрагменты текста удобно обрабатывать при помощи функции textInput(), а если вы хотите, чтобы пользователь ввел один или несколько абзацев, используйте textAreaInput(). Для нашего классификатора подойдет последняя. Добавьте ее вместо ползунка:

textAreaInput("user_text", 
              NULL, 
              placeholder = "Введите текст новости здесь...", 
              rows = 6)

Для сравнения добавьте рядом (чуть позже мы это уберем):

textInput("user_text", 
          # заметьте положение вопроса
          "Как вас зовут?")

В серверной части нужна функция вывода (мы поговорим о ней чуть позже):

server <- function(input, output) {
  
  output$value <- renderText({ 
    input$user_text 
  })
  
}

Вывод отражаем на главной панели:

mainPanel(
           width = 6,
           h4("Результат классификации:"),
           verbatimTextOutput("value")
        )
Задание

Настройте приложение так, чтобы оно вежливо здоровалось с пользователем по имени. Обратите внимание на то, что происходит при этом с выводом.

29.2.3 Кнопки

До этого момента наше приложение реагировало на каждую букву, которую мы печатали. Если текст новости длинный, Shiny будет пытаться запустить классификацию (а это тяжелые математические расчеты) при вводе каждого символа. Приложение начнет жутко тормозить.Чтобы этого не происходило, нам нужен “тормоз” — кнопка. Мы хотим, чтобы код классификации запускался только тогда, когда пользователь полностью вставил текст и осознанно нажал на кнопку. Добавьте кнопку под полем ввода текста в UI:

actionButton("predict_btn", "🔍 Предсказать категорию", class = "btn-primary")

Сама по себе кнопка в UI — это просто красивая картинка. Чтобы она ожила, в коде сервера нужно использовать функцию bindEvent(). Она буквально означает: “Замкни выполнение этого кода на кнопку”. Пока мы не использовали эти функции, так что наша кнопка бездействует.

Вы можете менять цвет кнопки, меняя её “класс”:

  • class = "btn-primary" — cиняя (основное действие, стандарт) 🔵
  • class = "btn-success" — зеленая (успех, сохранение) 🟢
  • class = "btn-danger" — красная (опасность, удаление) 🔴

Наконец, вы можете заставить кнопку занять всю свободную ширину внутри элемента, в который она встроена, используя значение "btn-block".

Чтобы кнопка заработала, в серверной части пишем:

server <- function(input, output) {
  
  output$value <- reactive({ 
    input$user_text
  }) |> 
    bindEvent(input$predict_btn)
  
}
Задание

Модифицируйте код сервера так, чтобы текст из поля textAreaInput не появлялся на экране до тех пор, пока пользователь не нажмет на синюю кнопку “Предсказать категорию”. (Приветствие нам больше не нужно, удалите его.)

29.2.4 Элементы вывода

Элементы вывода (output) представляют собой своеобразные заглушки в интерфейсе пользователя, которые при необходимости заполняются данными из функции server().

Как и элементы ввода, элементы вывода принимают уникальный идентификатор в качестве первого аргумента. Если в пользовательском интерфейсе есть заглушка с ID “salutation”, то в серверной части мы наполняем ее через output$salutation.

Каждой функции вывода в клиентской части (UI) строго соответствует функция отображения в серверной. Существует три основных типа вывода: текст, таблицы, графики.

Выше мы уже научились добавлять обычный реактивный текст.

На заметку

Реактивное программирование - это стиль программирования, при котором данные и вычисления автоматически обновляются в ответ на изменения входных данных. Вы просто связываете input и output, а Shiny сам следит за тем, что нужно пересчитать.

“Укротить” реактивный ввод можно с помощью кнопки, это мы тоже уже сделали. Теперь, когда мы разобрались с текстом и кнопками, перейдем к двум другим важным типам вывода.

29.3 Промежуточный итог

На этом этапе у вас должно получиться вот что:

library(shiny)

# пользовательский интерфейс
ui <- fluidPage(

    # название приложения
    titlePanel("📰 Классификатор новостей"),

    # макет
    sidebarLayout(
      
        sidebarPanel(
            width = 6,
            tags$h4("Вставьте или напечатайте новость:"),
            textAreaInput("user_text", 
                          NULL, 
                          placeholder = "Введите текст новости здесь...", 
                          rows = 6),
            actionButton("predict_btn", 
                         "🔍 Предсказать категорию", 
                         class = "btn-primary")
            ),
        
        mainPanel(
           width = 6,
           tags$h3("Результаты классификации"),
           
        )
    )
)

# сервер (пока пустой)
server <- function(input, output) {
  
  # пока пусто
  
}

# поехали! 
shinyApp(ui = ui, server = server)

29.4 Сервер

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

29.4.1 Подготовка окружения

После этого прочитайте в окружение препроцессор и модель и загрузите нужные пакеты. Все пакеты и файлы должны загружаться в самом начале скрипта, до того как начнутся блоки ui и server. Также нам понадобятся имена для классов: они соответствуют уровням фактора, который мы создали в прошлый раз при помощи as.factor(class).

library(shiny)
library(keras3)
library(recipes)
library(tibble)
library(dplyr)
library(stringr)
library(purrr)
library(ggplot2)
library(textrecipes)


# Загрузка обученного препроцессора и модели
onehot_rec <- readRDS("onehot_prep.rds")
model <- load_model("my_dense_model.keras")

# Список названий классов 
class_names <- c("Business", "Sci/Tech", "Sports", "World") 

29.4.2 Фронтенд

Интерфейс содержит поле для ввода текста и кнопку для запуска предсказания.

ui <- fluidPage(

    # название приложения
    titlePanel("📰 Классификатор новостей"),

    # макет
    sidebarLayout(
        sidebarPanel(
            width = 6,
            tags$h4("Вставьте или напечатайте новость:"),
            textAreaInput("user_text", NULL, placeholder = "Введите текст новости здесь...", rows = 6),
            actionButton("predict_btn", "🔍 Предсказать категорию", class = "btn-primary")
            ),
        mainPanel(
           width = 6,
           tags$h3("Результаты классификации"),
           textOutput("result_text")
        )
    )
)

29.4.3 Бэкенд

Обученный рецепт применяется к новым данным через bake(). Перед этим строку, которую ввел пользователь, нужно преобразовать в тиббл с теми же названиями столбцов, которые ожидает препроцессор.

Функция reactive() создает реактивное выражение. Реактивные выражения — это особые части кода, которые автоматически пересчитываются при изменении входных переменных.

Реактивные выражения используются, чтобы:

  • выполнить вычисления, которые используете несколько раз, не дублируя код;
  • кэшировать результат и пересчитывать только тогда, когда реально изменились входные значения.
server <- function(input, output) {
  
  pred_result <- reactive({
    req(input$user_text)
    new_data <- tibble(description = input$user_text)
    model_input <- bake(onehot_rec, 
                        new_data = new_data,
                        composition = "matrix")
    probs <- as.numeric(
      model |> 
        predict(model_input)
      )
    pred_cat <- class_names[which.max(probs)]
    list(
      category = pred_cat,
      probs = setNames(probs, class_names)
    )
  }) |> 
    bindEvent(input$predict_btn)
  
  
  output$result_text <- renderText({
    req(pred_result())
      paste0(
        "🌟 Предсказанная категория: ", pred_result()$category)
  })
}

shinyApp(ui = ui, server = server)

В нашем случае реактивное выражение считает результат, только когда пользователь нажимает кнопку, за связь с кнопкой отвечает bindEvent(). Если бы этого не было, приложение реагировало бы на каждый введённый символ!

Вызов req(input$user_text) - это контроль ввода. Функция req() останавливает выполнение реактивного выражения, если в него передано NULL, FALSE и т.п. Это защищает от ошибок при отсутсвии необходимых данных.

На шаге new_data <- tibble(description = input$user_text) мы оборачиваем введённый текст в тиббл, чтобы дальше передать в препроцессор.

В конце вызываем list(...), который возвращает список с двумя значениями:
- category — категория с самой высокой вероятностью. - probs — вектор вероятностей для всех четырёх классов.

Почему список? В реактивных выражениях, как и в базовых функциях, можно вернуть только один объект. Если нужно передать сразу несколько разных значений, их упаковывают в список.

Наконец, output$result_text – это то, что будет отображено в textOutput("result_text") на главной странице приложения. Все, что находится внутри renderText({...}), автоматически обновляется, как только изменяется значение pred_result().

Обратите внимане на работу с реактивными переменными:

  • Вызов reactive({...}) создает не готовый результат, а инструкцию по вычислению.
  • Сам по себе pred_result ― не результат вычисления, а функция; чтобы извлечь конкретные значения, к ней надо обращаться так: pred_result() (именно со скобками!).

29.4.4 Пошаговая схема

Итак, как всё работает вместе?

  1. Пользователь вводит текст и нажимает кнопку.
  2. Только в этот момент запускается реактивное выражение pred_result():
    • Текст подготавливается, обрабатывается препроцессором, подаётся модели.
    • Получается вектор вероятностей по имеющимся категориям.
    • Определяется категория с максимальной вероятностью.
    • Результат пакуется в список.
  3. Блок renderText замечает, что функция pred_result() обновила своё значение. Он автоматически забирает финальную категорию и выводит красивую строку на экран пользователя.
(Пользователь вводит текст)
       │
       ▼
(Жмёт кнопку)
       │
       ▼
pred_result (реактивное выражение):
 ├─ 1. Обработка текста
 ├─ 2. Векторизация/onehot
 ├─ 3. Предсказание нейросетью
 └─ 4. Формирование списка с вероятностями и категорией
       │
       ▼
output$result_text (обновление на экране)

29.5 Кастомизация

Если вам не подходит обычный сырой текст, Shiny позволяет выводить полноценный HTML-код с инлайн-стилями или классами CSS.

В таком случае вместо связки renderText() и textOutput() используется пара renderUI() (на сервере) и uiOutput() (в интерфейсе):

# в серверной части
output$result_text <- renderUI({
    req(pred_result())
    HTML(
      paste0(
        "<h4>🌟 Предсказанная категория: <span style='color:#0072B2;'>", pred_result()$category, "</span></h4>"
      )
    )
  })


# в пользовательском интерфейсе
uiOutput("result_text")

Теперь попробуем усовершенстовать наше приложение, добавив график.

29.5.1 Добавление графика

Сначала посмотрим, как выглядит стандартный код для визуализации распределения вероятностей вне приложения (в обычном R-скрипте):

tibble(category = class_names, probability = probs)  |>  
  ggplot(aes(y = reorder(category, probability), x = probability, fill = category)) +
  geom_col(width = 0.6, show.legend = FALSE) +
  scale_fill_brewer(palette = "Set2") +
  scale_x_continuous(labels = scales::percent_format(accuracy = 1)) +
  labs(x = "Вероятность", y = "Категория") +
  theme_minimal(base_size = 15) +
  theme(
    axis.title.y = element_blank(),
    plot.title = element_text(face="bold"),
    axis.text = element_text(size=12)
  )

При переносе кода на сервер логика построения графика остаётся прежней. Однако нам нужно внести два изменения:

  • Обернуть код в функцию рендеринга renderPlot({...}).
  • Взять данные о вероятностях не из статичной переменной, а из нашего реактивного списка с помощью pred_result()$probs.
output$prob_plot <- renderPlot({
    req(pred_result())
    tibble(category = class_names,
      probability = pred_result()$probs)  |> 
      ggplot(aes(y = reorder(category, probability), x = probability, fill = category)) +
      geom_col(width = 0.6, show.legend = FALSE) +
      scale_fill_brewer(palette = "Set2") +
      scale_x_continuous(labels = scales::percent_format(accuracy = 1)) +
      labs(x = "Вероятность", y = "Категория") +
      theme_minimal(base_size = 15) +
      theme(
        axis.title.y = element_blank(),
        plot.title = element_text(face="bold"),
        axis.text = element_text(size=12)
      )
  })

В пользовательской части добавьте plotOutput("prob_plot", height = 250).

29.6 Оформление

По умолчанию Shiny-приложения выглядят стандартно и немного скучно. Однако вам не нужно учить веб-дизайн или верстку, чтобы это исправить. В экосистеме R есть замечательный пакет {bslib}, который позволяет менять внешний вид всего приложения буквально одной строчкой кода.

Пакет {bslib} дает доступ к коллекции готовых профессиональных тем (они называются темами Bootstrap).

library(bslib)
bslib::bs_theme_preview()

Как изменить тему приложения? Убедитесь, что пакет установлен и подключен в самом начале вашего скрипта: library(bslib). Внутри функции fluidPage() добавьте аргумент theme и выберите одну из встроенных тем с помощью функции bs_theme().

29.7 Публикация

Сейчас ваше Shiny-приложение работает только на вашем локальном компьютере. Чтобы им могли пользоваться другие люди (например, преподаватель или друзья), его нужно опубликовать в интернете. Самый простой, бесплатный и официальный способ сделать это — использовать облачный сервис <shinyapps.io> от компании Posit (бывшая RStudio).

Как опубликовать приложение за 3 шага:

  • Создайте аккаунт: Зарегистрируйтесь на сайте shinyapps.io. На бесплатном тарифе вы можете выложить до 5 своих приложений.
  • Подключите компьютер к облаку: В правом верхнем углу окна RStudio нажмите на иконку синего круга со стрелкой (Publish) или перейдите в меню Tools -> Global Options -> Publishing.

Сайт shinyapps.io выдаст вам специальный токен (ключ безопасности) — скопируйте и вставьте его в RStudio для связи аккаунтов.

Нажмите кнопку “опубликовать”: Откройте ваш файл app.R, нажмите кнопку Publish на панели инструментов RStudio, выберите файлы проекта и подтвердите отправку. Через пару минут RStudio сама соберет приложение в облаке и откроет вкладку в браузере с готовой рабочей ссылкой.

Задание

Важное правило для публикации данных: убедитесь, что все файлы, которые считывает ваше приложение (датасеты .csv, обученные рецепты .rds или модели), лежат в той же папке, что и сам скрипт app.R. Пути к файлам в коде должны быть относительными: например, read_csv("data.csv"), а не абсолютными (C:/Users/Admin/Documents/data.csv), иначе в облаке приложение выдаст ошибку.

29.8 Отчетный проект

29.8.1 Задание

  • Это домашнее задание с процедурой взаимного оценивания (пир-ревью). Оценка: 0-10 баллов. Группа не больше 3 человек.
  • Стек: R, {shiny}, {bslib}, а также пакеты из пройденных модулей курса ({tidytext}, {ggraph}, {tidymodels} и др.)
  • Результат: Рабочее Shiny-приложение, опубликованное на <shinyapps.io> , и ссылка на репозиторий проекта на GitHub (добавляется на страницу приложения).
  • Проверка: каждая группа проверяет работу двух других групп и оценивает в Forms.
  • Состав команды обязательно указывайте на странице приложения.

29.8.2 Варианты тем для приложений

  • приложение для анализа эмоциональной тональности (возможность выбора лексикона);
  • приложение для автоматической классификации по жанру или автору;
  • приложение для векторного поиска по заданному корпусу (имейте в виду ограничения бесплатного тарифа, BERT он не потянет);
  • любые визуализации (облака слов, диаграммы);
  • сетевой анализ (задействуйте слайдер минимального веса связи или степени узла);
  • другое решение на выбор команды (можно предварительно согласовать с преподавателем).

29.8.3 Критерии оценивания

Оценка складывается из 5 блоков (максимум — 10 баллов):

Публикация и стабильность (0–2 балла)

  • 2 балла: Приложение доступно по ссылке на <shinyapps.io>, работает стабильно. На странице указаны авторы и есть рабочая ссылка на репозиторий GitHub.
  • 1 балл: Приложение запускается, но периодически падает с ошибками при вводе данных, или забыли указать ссылку на GitHub / состав команды.
  • 0 баллов: Приложение не открывается или выдает критическую ошибку при запуске.

Техническая реализация (0–2 балла)

  • 2 балла: В приложении правильно используется кнопка запуска (bindEvent или аналоги). Тяжелые вычисления (классификация, построение графа) запускаются только после клика, а не от каждой введенной буквы. Код опрятный и читаемый. В коде нет абсолютных путей (приложение воспроизводимо).
  • 1 балл: Кнопка есть, но приложение все равно считает «на лету» от каждой буквы (забыли привязать событие), из-за чего интерфейс тормозит.
  • 0 баллов: Реактивность сломана, интерфейс не обновляется.

Качество анализа текста и DH-компонент (0–2 балла)

  • 2 балла: Методы анализа текста (классификация, сентимент, сети или векторы) применены осмысленно. Есть содержательное описание приложения на главной странице.
  • 1 балл: Инструмент работает, но аналитическая часть не впечатляет.
  • 0 баллов: Вычисления некорректны, или выбранный метод не реализован.

Интерактивность и визуализации (0–2 балла)

  • 2 балла: Графики/таблицы построены аккуратно. Присутствует интерактивность.
  • 1 балл: Визуализации есть, но они статичны, перегружены или никак не меняются от настроек пользователя.
  • 0 баллов: Графиков/таблиц нет, либо они полностью нечитаемы.

Дизайн, UX и оформление (0–2 балла)

  • 2 балла: Использована тема оформления из пакета {bslib}. Интерфейс выглядит аккуратно и профессионально, элементы управления расположены логично, пользователю понятно, что делать.
  • 1 балл: Приложение оставлено с дизайном по умолчанию либо элементы UI расположены хаотично и мешают друг другу.
  • 0 баллов: Пользоваться приложением невозможно из-за грубых ошибок верстки.

29.8.4 Требования к оцениванию

  • Каждая команда проверяет 2 чужих проекта. Суммарно за оба проекта вы можете выставить не более 16 баллов, при нарушении этого требования возможен штраф.
  • Если оценка рецензентов сильно расходится с оценкой преподавателя, проверяющая группа может получить штраф к своему собственному проекту.
  • Если вы ставите по критерию максимальный балл (2 из 2), вы обязаны написать в текстовом поле не менее 2 предложений с подробным разбором, почему это решение идеальное. Если вы ставите 1 или 0 баллов, вы обязаны детально расписать ошибку и дать совет по исправлению. Ответы в духе “все ок”, “супер” и т.п. не принимаются. За пустые обоснования оценка проверяющей группы может быть снижена.
  • Результаты пир-ревью могут быть скорректированы преподавателем.

29.8.5 Дедлайн

  • Приложения сдаются до пятницы 19 июня 18:00. Форма сдачи: ссылка в чат курса.
  • На занятии 19 июня каждая команда проводит презентацию, которая может быть учтена оценивающей командой и преподавателем. Различные блоки презентации должны быть представлены всеми ответственными за блоки разработки. Отсутствие на представлении проекта является основанием для снижения оценки участнику проекта.
  • Оценивание приложений всеми командами проводится до понедельника 22 июня 23:59 через форму (будет добавлена позже).