Анализ PeerMathDial: различия между версиями

Материал из Поле цифровой дидактики
Новая страница: «{{DISPLAYTITLE:От открытых диалогов PeerMathDial к социограммам в R}} = От открытых диалогов PeerMathDial к социограммам в R = В этом уроке мы пройдём полный воспроизводимый путь: прочитаем открытый JSON Lines непосредственно из GitHub, исследуем вложенную структуру, превратим се...»
 
 
(не показано 5 промежуточных версий этого же участника)
Строка 1: Строка 1:
{{DISPLAYTITLE:От открытых диалогов PeerMathDial к социограммам в R}}
= От открытых диалогов PeerMathDial к социограммам в R =
= От открытых диалогов PeerMathDial к социограммам в R =


В этом уроке мы пройдём полный воспроизводимый путь: прочитаем открытый JSON Lines непосредственно из GitHub, исследуем вложенную структуру, превратим сессии в таблицу реплик, проведём аудит и построим две социограммы. Первая социограмма — двудольный граф «ученик — сессия», вторая — ориентированный граф «следующий говорящий» для выбранной сессии.
В этом уроке мы пройдём полный воспроизводимый путь: прочитаем открытый [[JSON]] Lines непосредственно из [[GitHub]], исследуем вложенную структуру, превратим сессии в таблицу реплик, проведём аудит и построим две социограммы. Первая социограмма — двудольный граф «ученик — сессия», вторая — ориентированный граф «следующий говорящий» для выбранной сессии.
 
Исходные данные: [https://github.com/Ziyu-Yao-NLP-Lab/PeerMathDial PeerMathDial] и [https://raw.githubusercontent.com/Ziyu-Yao-NLP-Lab/PeerMathDial/refs/heads/main/data/conversations.jsonl файл conversations.jsonl]. Каждая строка файла — отдельный JSON-объект сессии; внутри сессии находится список реплик <code>turns</code>. В публичном описании корпуса указаны 55 диалогов и 6406 реплик.


Код ниже — '''один сквозной скрипт'''. Блоки следует запускать сверху вниз в одной сессии R. Здесь нет заранее скачанного файла, скрытых функций или закрытых модулей: промежуточные таблицы остаются в рабочем окружении, и студент может исследовать или изменить любой шаг.
; Исходные данные - https://github.com/Ziyu-Yao-NLP-Lab/PeerMathDial
: [https://github.com/Ziyu-Yao-NLP-Lab/PeerMathDial PeerMathDial] и
: https://raw.githubusercontent.com/Ziyu-Yao-NLP-Lab/PeerMathDial/refs/heads/main/data/conversations.jsonl
Каждая строка файла — отдельный [[JSON]]-объект сессии; внутри сессии находится список реплик <code>turns</code>. В публичном описании корпуса указаны 55 диалогов и 6406 реплик.


<div class="note">
'''Методологическое ограничение.''' В корпусе нет надёжно размеченного адресата каждой реплики. Поэтому связь <code>A → B</code> означает только то, что реплика B непосредственно следует за репликой A. Это самый простой и наименее надёжный способ восстановить адресата: B мог отвечать не A, обращаться ко всей группе, продолжать собственную мысль или реагировать на невербальное действие. Такой граф описывает последовательность смены говорящих, а не доказанную коммуникацию «кто к кому обратился».
</div>


== Результат урока ==
== Результат урока ==
Строка 27: Строка 23:
== 0. Параметры исследования ==
== 0. Параметры исследования ==


Сначала соберём в одном месте все значения, которые студенту разрешено менять. Порог ядра равен 5 % всех реплик сессии. Один и тот же <code>edge_min_events</code> применяется к рёбрам всех графов. Толщина ребра линейно переводит число событий <math>x</math> в диапазон от <math>w_{min}</math> до <math>w_{max}</math>:
Сначала соберём в одном месте все значения.
Порог ядра равен 5 % всех реплик сессии. Один и тот же <code>edge_min_events</code> применяется к рёбрам всех графов. Толщина ребра линейно переводит число событий <math>x</math> в диапазон от <math>w_{min}</math> до <math>w_{max}</math>:


<math>w = w_{min} + \frac{x-x_{min}}{x_{max}-x_{min}}(w_{max}-w_{min})</math>.
<math>w = w_{min} + \frac{x-x_{min}}{x_{max}-x_{min}}(w_{max}-w_{min})</math>.
Строка 34: Строка 31:


<syntaxhighlight lang="r">
<syntaxhighlight lang="r">
# ============================================================
# 0. ПАРАМЕТРЫ: все настраиваемые величины находятся здесь
# ============================================================
# ============================================================


Строка 56: Строка 51:
#  (events - min(events)) / (max(events) - min(events)) *
#  (events - min(events)) / (max(events) - min(events)) *
#  (edge_width_max - edge_width_min)
#  (edge_width_max - edge_width_min)
</syntaxhighlight>
'''Проверьте глазами:''' <code>core_share</code> записан как доля, поэтому 5 % — это <code>0.05</code>; <code>edge_min_events</code> — целое число событий; номер сессии начинается с 1.
== 1. Пакеты ==
Проверяем четыре общеупотребительных пакета и устанавливаем только отсутствующие. <code>igraph</code> и <code>DiagrammeR</code> здесь не нужны: все строки DOT будут собраны открытым кодом через <code>sprintf()</code> и <code>paste()</code>.
<syntaxhighlight lang="r">
# ============================================================
# 1. ПРОВЕРКА И ПОДКЛЮЧЕНИЕ ПАКЕТОВ
# ============================================================


packages <- c("jsonlite", "dplyr", "tidyr", "purrr")
packages <- c("jsonlite", "dplyr", "tidyr", "purrr")
Строка 79: Строка 62:


invisible(lapply(packages, library, character.only = TRUE))
invisible(lapply(packages, library, character.only = TRUE))
</syntaxhighlight>
'''Проверьте глазами:''' <code>missing_packages</code> должен быть пустым после установки. Команда <code>sessionInfo()</code> покажет версии R и подключённых пакетов.
<syntaxhighlight lang="r">
sessionInfo()
</syntaxhighlight>
== 2. Загрузка и осмотр JSONL ==
JSON Lines — это текст, в котором каждая строка содержит самостоятельный JSON-объект. Мы читаем строки прямо по URL, а затем разбираем каждую строку отдельно. Параметр <code>simplifyVector = FALSE</code> сохраняет вложенные объекты как списки: это удобно для первого знакомства со структурой, потому что R не пытается преждевременно превратить всё в плоскую таблицу.


<syntaxhighlight lang="r">
# ============================================================
# ============================================================
# 2. ЗАГРУЗКА JSONL НАПРЯМУЮ ИЗ GITHUB
# 2. ЗАГРУЗКА JSONL НАПРЯМУЮ ИЗ GITHUB
Строка 106: Строка 77:
length(json_lines)
length(json_lines)
length(sessions_list)
length(sessions_list)
</syntaxhighlight>


Теперь не угадываем схему, а спрашиваем её у первого объекта. Сначала смотрим поля сессии, затем поля первой реплики и только после этого обращаемся к <code>speaker_id</code> и <code>role</code>.


<syntaxhighlight lang="r">
# Поля верхнего уровня первой сессии
# Поля верхнего уровня первой сессии
names(sessions_list[[1]])
names(sessions_list[[1]])
Строка 132: Строка 100:


roles_in_file
roles_in_file
</syntaxhighlight>


'''Проверьте глазами:''' у сессии должны быть, среди прочего, поля <code>session_id</code>, <code>speakers</code> и <code>turns</code>. У реплики должны быть <code>turn_id</code>, <code>speaker_id</code>, <code>role</code>, <code>text</code>, <code>word_count</code> и вложенное <code>action</code>. В текущей версии данных роли имеют значения <code>student</code>, <code>teacher</code> и <code>unknown</code>. Не перекодируйте <code>unknown</code> в ученика: это было бы новым предположением, которого нет в данных.
== 3. Одна строка — одна реплика ==
Создаём таблицу с одной строкой на сессию и двумя list-column: исходным объектом сессии и списком реплик. Затем <code>unnest_longer()</code> разворачивает список реплик вниз, а <code>unnest_wider()</code> раскрывает поля каждой реплики вправо.
<syntaxhighlight lang="r">
# ============================================================
# ============================================================
# 3. РАЗВОРАЧИВАНИЕ В ДЛИННУЮ ТАБЛИЦУ
# 3. РАЗВОРАЧИВАНИЕ В ДЛИННУЮ ТАБЛИЦУ
Строка 158: Строка 118:
   dplyr::select(-session) |>
   dplyr::select(-session) |>
   head(3)
   head(3)
</syntaxhighlight>


'''Проверьте глазами:''' <code>nrow(sessions_tbl)</code> равно числу строк исходного JSONL. Столбец <code>turns</code> пока остаётся списком.
<syntaxhighlight lang="r">
turns_long <- sessions_tbl |>
turns_long <- sessions_tbl |>
   dplyr::select(session_order, session_id, turns) |>
   dplyr::select(session_order, session_id, turns) |>
Строка 189: Строка 145:
dplyr::glimpse(turns_long)
dplyr::glimpse(turns_long)
nrow(turns_long)
nrow(turns_long)
</syntaxhighlight>


'''Проверьте глазами:''' одна строка теперь соответствует одной реплике. <code>turn_no</code> берётся из самого JSON, а <code>turn_index</code> — позиция элемента в списке; следующая проверка должна вернуть ноль несовпадений.
<syntaxhighlight lang="r">
turns_long |>
turns_long |>
   dplyr::summarise(
   dplyr::summarise(
Строка 203: Строка 155:


turns_long |> dplyr::count(role, sort = TRUE)
turns_long |> dplyr::count(role, sort = TRUE)
</syntaxhighlight>
== 4. Аудит корпуса ==


Аудит отвечает на четыре вопроса: сколько сессий и реплик, сколько уникальных идентификаторов учеников, есть ли сессии без реплик с ролью <code>student</code>, и какова доля реплик учителя в каждой сессии. Здесь «число учеников» означает число уникальных <code>speaker_id</code> с ролью <code>student</code> именно в <code>conversations.jsonl</code>; оно не обязано совпадать с числом анкетированных или фокусных учеников в других файлах корпуса.


<syntaxhighlight lang="r">
# ============================================================
# ============================================================
# 4. АУДИТ КОРПУСА
# 4. АУДИТ КОРПУСА
Строка 224: Строка 171:


corpus_audit
corpus_audit
</syntaxhighlight>
'''Проверьте глазами:''' для текущей публикации первые два значения должны быть 55 сессий и 6406 реплик. Третье значение — уникальные говорящие с явно указанной ролью <code>student</code> в этом файле.


<syntaxhighlight lang="r">
session_audit <- turns_long |>
session_audit <- turns_long |>
   dplyr::group_by(session_order, session_id) |>
   dplyr::group_by(session_order, session_id) |>
Строка 254: Строка 197:
teacher_share_by_session |> head(10)
teacher_share_by_session |> head(10)
summary(teacher_share_by_session$teacher_share)
summary(teacher_share_by_session$teacher_share)
</syntaxhighlight>
'''Проверьте глазами:''' <code>teacher_share</code> находится между 0 и 1. Сессии в <code>sessions_without_students</code> не удаляются автоматически: они важны для аудита, хотя в двудольном графе у них не будет рёбер к ученикам.
Проверим также, не связан ли один и тот же идентификатор с несколькими ролями. Пустой результат означает, что роли идентификаторов согласованы внутри файла.


<syntaxhighlight lang="r">
speaker_role_conflicts <- turns_long |>
speaker_role_conflicts <- turns_long |>
   dplyr::distinct(speaker_id, role) |>
   dplyr::distinct(speaker_id, role) |>
Строка 268: Строка 205:
speaker_role_conflicts
speaker_role_conflicts
nrow(speaker_role_conflicts)
nrow(speaker_role_conflicts)
</syntaxhighlight>


== 5. Граница группы ==
Для каждого сочетания «ученик — сессия» считаем реплики ученика и делим их на '''все''' реплики сессии, включая реплики учителя и неопознанных говорящих. Ученик относится к ядру, если его доля не меньше <code>core_share</code>; иначе участие считается эпизодическим. Выбор знаменателя — содержательное решение: если делить только на ученические реплики, границы ядра изменятся.
<syntaxhighlight lang="r">
# ============================================================
# ============================================================
# 5. ЯДРО И ЭПИЗОДИЧЕСКОЕ УЧАСТИЕ
# 5. ЯДРО И ЭПИЗОДИЧЕСКОЕ УЧАСТИЕ
Строка 299: Строка 230:
student_session |> dplyr::count(participation_type)
student_session |> dplyr::count(participation_type)
summary(student_session$turn_share)
summary(student_session$turn_share)
</syntaxhighlight>
'''Проверьте глазами:''' для каждой строки <code>student_turns &lt;= session_turns</code>. Изменение единственного параметра <code>core_share</code> при повторном запуске этого и следующих блоков должно менять тип части рёбер, но не исходные числа реплик.


== 6. Граф 1: ученик — сессия ==
Строим неориентированный двудольный граф. Ребро означает участие ученика в сессии, его вес равен числу реплик ученика. Ребро попадает на рисунок, только если его вес не меньше общего порога <code>edge_min_events</code>. Ядро показывается сплошной линией, эпизодическое участие — пунктиром.
Все ученики и все сессии сохраняются как узлы, даже если после применения порога у них не осталось рёбер. Поэтому аудит-сессии без учеников и пороговые изоляты не исчезают молча.
<syntaxhighlight lang="r">
# ============================================================
# ============================================================
# 6. ГРАФ 1: ДВУДОЛЬНЫЙ «УЧЕНИК — СЕССИЯ»
# 6. ГРАФ 1: ДВУДОЛЬНЫЙ «УЧЕНИК — СЕССИЯ»
Строка 410: Строка 331:
   useBytes = TRUE
   useBytes = TRUE
)
)
</syntaxhighlight>
'''Проверьте глазами:''' файл начинается с <code>graph</code>, а рёбра записаны оператором <code>--</code>. В строках узлов нет имён в <code>label</code>: ученик обозначен 👤 и кругом, сессия — 📄 и квадратом. Толщина каждого ребра лежит в заданном диапазоне.
== 7. Граф 2: следующий говорящий ==


Выбираем сессию по её порядку в исходном JSONL, а не по случайной сортировке идентификаторов. Сначала создаём все переходы между соседними репликами и лишь затем агрегируем одинаковые пары. Самопереходы <code>A → A</code> сохраняются: они означают две соседние реплики одного говорящего и не должны исчезать из последовательности без явного решения исследователя.
<syntaxhighlight lang="r">
# ============================================================
# ============================================================
# 7. ГРАФ 2: «СЛЕДУЮЩИЙ ГОВОРЯЩИЙ» С УЧИТЕЛЕМ
# 7. ГРАФ 2: «СЛЕДУЮЩИЙ ГОВОРЯЩИЙ» С УЧИТЕЛЕМ
Строка 437: Строка 350:
   dplyr::select(turn_no, speaker_id, role, text) |>
   dplyr::select(turn_no, speaker_id, role, text) |>
   head(15)
   head(15)
</syntaxhighlight>
'''Проверьте глазами:''' <code>turn_no</code> возрастает, а <code>selected_session_id</code> соответствует выбранному <code>session_number</code>.


<syntaxhighlight lang="r">
transition_nodes_with_teacher <- selected_turns |>
transition_nodes_with_teacher <- selected_turns |>
   dplyr::distinct(speaker_id, role) |>
   dplyr::distinct(speaker_id, role) |>
Строка 458: Строка 367:
sum(transitions_with_teacher$n_events)
sum(transitions_with_teacher$n_events)
nrow(selected_turns) - 1L
nrow(selected_turns) - 1L
</syntaxhighlight>
'''Проверьте глазами:''' сумма <code>n_events</code> должна быть ровно на единицу меньше числа реплик выбранной сессии. Это контроль того, что каждая соседняя пара учтена один раз.
=== Матрица переходов ===


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


<syntaxhighlight lang="r">
all_speakers_with_teacher <- transition_nodes_with_teacher$speaker_id
all_speakers_with_teacher <- transition_nodes_with_teacher$speaker_id


Строка 482: Строка 384:


transition_matrix_with_teacher
transition_matrix_with_teacher
</syntaxhighlight>
'''Проверьте глазами:''' сумма всех ячеек матрицы совпадает с <code>nrow(selected_turns) - 1</code>. Диагональ содержит самопереходы.


=== Взаимные пары ===
Взаимной считаем неориентированную пару A–B, для которой есть не менее <code>edge_min_events</code> переходов A → B и не менее того же порога B → A. Условие <code>from &lt; to</code> оставляет каждую пару один раз и исключает самопереходы.
<syntaxhighlight lang="r">
mutual_pairs_with_teacher <- transitions_with_teacher |>
mutual_pairs_with_teacher <- transitions_with_teacher |>
   dplyr::filter(from != to, n_events >= edge_min_events) |>
   dplyr::filter(from != to, n_events >= edge_min_events) |>
Строка 520: Строка 414:
mutual_pairs_with_teacher
mutual_pairs_with_teacher
nrow(mutual_pairs_with_teacher)
nrow(mutual_pairs_with_teacher)
</syntaxhighlight>


=== Рёбра и изолированные ученики ===


Видимые рёбра фильтруются тем же <code>edge_min_events</code>, что и двудольный граф. Изолированный ученик — это ученик выбранной сессии, который после применения порога не входит ни в одно видимое ребро. Это '''пороговая изоляция''', а не доказательство отсутствия речи: участник мог иметь редкие переходы, скрытые порогом.
<syntaxhighlight lang="r">
transition_edges_with_teacher <- transitions_with_teacher |>
transition_edges_with_teacher <- transitions_with_teacher |>
   dplyr::filter(n_events >= edge_min_events)
   dplyr::filter(n_events >= edge_min_events)
Строка 558: Строка 447:
isolated_students_with_teacher
isolated_students_with_teacher
nrow(isolated_students_with_teacher)
nrow(isolated_students_with_teacher)
</syntaxhighlight>
=== Ручная сборка DOT ===
Учитель получает <code>shape=doublecircle</code>, ученик — <code>shape=circle</code>; оба обозначены 👤. В данных встречается также роль <code>unknown</code>: такой говорящий остаётся в последовательности как человек с кругом, но его точная роль указана во всплывающей подсказке. Это сохраняет наблюдаемую последовательность, не выдавая неопознанного говорящего за ученика.


<syntaxhighlight lang="r">
transition_node_lines_with_teacher <- transition_nodes_with_teacher |>
transition_node_lines_with_teacher <- transition_nodes_with_teacher |>
   dplyr::mutate(
   dplyr::mutate(
Строка 627: Строка 510:
   useBytes = TRUE
   useBytes = TRUE
)
)
</syntaxhighlight>
'''Проверьте глазами:''' файл начинается с <code>digraph</code>, а рёбра используют <code>-></code>. Все видимые переходы показаны сплошными линиями; число событий скрыто в <code>tooltip</code>, а не написано поверх социограммы.
== 8. Вариант без учителя ==
Важно удалить реплики учителя '''до''' вычисления <code>lead()</code>. Тогда две ученические реплики, между которыми раньше стояла реплика учителя, становятся соседними и создают новый прямой переход. Это не то же самое, что удалить учительский узел и его рёбра из уже готового графа.


<syntaxhighlight lang="r">
# ============================================================
# ============================================================
# 8. ВАРИАНТ: УЧИТЕЛЬ УДАЛЁН ДО ПОСТРОЕНИЯ ПЕРЕХОДОВ
# 8. ВАРИАНТ: УЧИТЕЛЬ УДАЛЁН ДО ПОСТРОЕНИЯ ПЕРЕХОДОВ
Строка 660: Строка 535:
sum(transitions_without_teacher$n_events)
sum(transitions_without_teacher$n_events)
nrow(selected_turns_without_teacher) - 1L
nrow(selected_turns_without_teacher) - 1L
</syntaxhighlight>


'''Проверьте глазами:''' сумма переходов снова на единицу меньше числа оставшихся реплик. Роль <code>unknown</code> не удаляется: этот вариант отвечает именно на вопрос об исключении учителя.
<syntaxhighlight lang="r">
all_speakers_without_teacher <- transition_nodes_without_teacher$speaker_id
all_speakers_without_teacher <- transition_nodes_without_teacher$speaker_id


Строка 709: Строка 580:


mutual_pairs_without_teacher
mutual_pairs_without_teacher
</syntaxhighlight>


Далее применяем тот же порог и ту же формулу толщины. Код намеренно повторён, а не спрятан в функцию: студент видит, что именно остаётся неизменным в двух аналитических вариантах.
<syntaxhighlight lang="r">
transition_edges_without_teacher <- transitions_without_teacher |>
transition_edges_without_teacher <- transitions_without_teacher |>
   dplyr::filter(n_events >= edge_min_events)
   dplyr::filter(n_events >= edge_min_events)
Строка 803: Строка 670:
   useBytes = TRUE
   useBytes = TRUE
)
)
</syntaxhighlight>


'''Проверьте глазами:''' в этом DOT нет узлов с <code>shape=doublecircle</code>. Идентификаторы по-прежнему находятся только в подсказках.
=== Сравнение вариантов ===
Сравним не только рисунки, но и таблицы. После удаления учителя уменьшается число реплик и исчезают переходы с учительскими узлами, однако могут появиться или усилиться связи между оставшимися говорящими, которые раньше были разделены вмешательством учителя.
<syntaxhighlight lang="r">
transition_comparison <- tibble::tibble(
transition_comparison <- tibble::tibble(
   variant = c("with_teacher", "without_teacher"),
   variant = c("with_teacher", "without_teacher"),
Строка 837: Строка 696:


transition_comparison
transition_comparison
</syntaxhighlight>
'''Проверьте глазами:''' не ожидайте, что вариант без учителя получится простым вычитанием учительских рёбер. Последовательность была сжата, поэтому набор соседних пар пересчитан.
== 9. Graphviz и MediaWiki ==
Скрипт уже записал три файла DOT. Если системный Graphviz установлен локально, следующие команды создадут SVG. Для двудольного графа используется движок <code>fdp</code>, для ориентированных графов — <code>neato</code>.


<syntaxhighlight lang="r">
# ============================================================
# ============================================================
# 9. НЕОБЯЗАТЕЛЬНАЯ ЛОКАЛЬНАЯ ОТРИСОВКА GRAPHVIZ
# НЕОБЯЗАТЕЛЬНАЯ ЛОКАЛЬНАЯ ОТРИСОВКА GRAPHVIZ
# ============================================================
# ============================================================


Строка 887: Строка 738:
   message("Команда neato не найдена: DOT всё равно создан.")
   message("Команда neato не найдена: DOT всё равно создан.")
}
}
</syntaxhighlight>
'''Проверьте глазами:''' <code>file.exists()</code> должен подтвердить наличие DOT-файлов; SVG появятся только при наличии системных команд Graphviz.
<syntaxhighlight lang="r">
file.exists(file.path(output_dir, "student_session_bipartite.dot"))
file.exists(file.path(output_dir, "next_speaker_with_teacher.dot"))
file.exists(file.path(output_dir, "next_speaker_without_teacher.dot"))
</syntaxhighlight>
На Поле цифровой дидактики DOT можно вставить между тегами <code>&lt;graphviz&gt;</code> и <code>&lt;/graphviz&gt;</code>. Следующий код готовит три текстовых файла, содержимое которых можно целиком скопировать в режим редактирования вики-страницы.
<syntaxhighlight lang="r">
# Файлы, готовые для вставки в MediaWiki с расширением Graphviz
writeLines(
  enc2utf8(c("<graphviz>", bipartite_dot, "</graphviz>")),
  file.path(output_dir, "student_session_bipartite.wiki.txt"),
  useBytes = TRUE
)
writeLines(
  enc2utf8(c(
    "<graphviz>",
    transition_dot_with_teacher,
    "</graphviz>"
  )),
  file.path(output_dir, "next_speaker_with_teacher.wiki.txt"),
  useBytes = TRUE
)
writeLines(
  enc2utf8(c(
    "<graphviz>",
    transition_dot_without_teacher,
    "</graphviz>"
  )),
  file.path(output_dir, "next_speaker_without_teacher.wiki.txt"),
  useBytes = TRUE
)
</syntaxhighlight>
Минимальная схема вставки выглядит так:


<syntaxhighlight lang="text">
<syntaxhighlight lang="text">
Строка 940: Строка 749:
Если вместо 👤 или 📄 отображается пустой квадрат, это проблема шрифта Graphviz, а не данных. Измените только параметр <code>emoji_font</code> в начале скрипта на шрифт с поддержкой этих символов, доступный на вашей системе, и повторите блоки экспорта.
Если вместо 👤 или 📄 отображается пустой квадрат, это проблема шрифта Graphviz, а не данных. Измените только параметр <code>emoji_font</code> в начале скрипта на шрифт с поддержкой этих символов, доступный на вашей системе, и повторите блоки экспорта.


== 10. Как читать два графа ==
== Как читать два графа ==


'''Двудольный граф''' отвечает на вопрос, какие ученики присутствуют в каких сессиях и насколько интенсивно. Он не показывает порядок реплик. Толстое ребро означает больше реплик ученика в сессии; сплошное ребро — прохождение относительного порога ядра; пунктир — эпизодическое участие.
'''Двудольный граф''' отвечает на вопрос, какие ученики присутствуют в каких сессиях и насколько интенсивно. Он не показывает порядок реплик. Толстое ребро означает больше реплик ученика в сессии; сплошное ребро — прохождение относительного порога ядра; пунктир — эпизодическое участие.
Строка 948: Строка 757:
'''Вариант с учителем''' сохраняет наблюдаемую хронологию и показывает учительские вмешательства как отдельный тип узла. '''Вариант без учителя''' отвечает на другой вопрос: как выглядит последовательность оставшихся говорящих после удаления учительских реплик. Новые связи в нём являются результатом сжатия последовательности и не должны автоматически трактоваться как прямые ответы учеников друг другу.
'''Вариант с учителем''' сохраняет наблюдаемую хронологию и показывает учительские вмешательства как отдельный тип узла. '''Вариант без учителя''' отвечает на другой вопрос: как выглядит последовательность оставшихся говорящих после удаления учительских реплик. Новые связи в нём являются результатом сжатия последовательности и не должны автоматически трактоваться как прямые ответы учеников друг другу.


== 11. Упражнения ==
== Упражнения ==


# Измените <code>core_share</code> с 0.05 на 0.10, повторите блоки 5–6 и сравните число сплошных и пунктирных рёбер. Объясните, почему веса рёбер не изменились, а тип некоторых рёбер изменился.
# Измените <code>core_share</code> с 0.05 на 0.10, повторите блоки 5–6 и сравните число сплошных и пунктирных рёбер. Объясните, почему веса рёбер не изменились, а тип некоторых рёбер изменился.

Текущая версия от 18:38, 9 октября 2026

От открытых диалогов PeerMathDial к социограммам в R

В этом уроке мы пройдём полный воспроизводимый путь: прочитаем открытый JSON Lines непосредственно из GitHub, исследуем вложенную структуру, превратим сессии в таблицу реплик, проведём аудит и построим две социограммы. Первая социограмма — двудольный граф «ученик — сессия», вторая — ориентированный граф «следующий говорящий» для выбранной сессии.

Исходные данные - https://github.com/Ziyu-Yao-NLP-Lab/PeerMathDial
PeerMathDial и
https://raw.githubusercontent.com/Ziyu-Yao-NLP-Lab/PeerMathDial/refs/heads/main/data/conversations.jsonl

Каждая строка файла — отдельный JSON-объект сессии; внутри сессии находится список реплик turns. В публичном описании корпуса указаны 55 диалогов и 6406 реплик.


Результат урока

После выполнения скрипта будут получены:

  • длинная таблица turns_long, где одна строка соответствует одной реплике;
  • аудит сессий, реплик, учеников и доли реплик учителя;
  • таблица границ группы: ядро и эпизодические участники;
  • student_session_bipartite.dot — двудольный граф «ученик — сессия»;
  • next_speaker_with_teacher.dot — переходы между соседними репликами с учителями;
  • next_speaker_without_teacher.dot — вариант, где учитель удалён из последовательности до вычисления переходов;
  • варианты тех же графов в обёртке <graphviz>...</graphviz> для вставки на страницу Поля цифровой дидактики.

0. Параметры исследования

Сначала соберём в одном месте все значения. Порог ядра равен 5 % всех реплик сессии. Один и тот же edge_min_events применяется к рёбрам всех графов. Толщина ребра линейно переводит число событий [math]\displaystyle{ x }[/math] в диапазон от [math]\displaystyle{ w_{min} }[/math] до [math]\displaystyle{ w_{max} }[/math]:

[math]\displaystyle{ w = w_{min} + \frac{x-x_{min}}{x_{max}-x_{min}}(w_{max}-w_{min}) }[/math].

Если все видимые рёбра имеют одинаковый вес, им назначается середина диапазона.

# ============================================================

data_url <- paste0(
  "https://raw.githubusercontent.com/Ziyu-Yao-NLP-Lab/",
  "PeerMathDial/refs/heads/main/data/conversations.jsonl"
)

core_share <- 0.05          # ядро: не менее 5 % всех реплик сессии
edge_min_events <- 2L       # единый порог включения ребра во все графы
session_number <- 1L        # номер сессии в порядке строк JSONL
edge_width_min <- 0.45      # минимальный penwidth
edge_width_max <- 3.80      # максимальный penwidth
arrow_size <- 0.45          # маленькая стрелка ориентированного графа
emoji_font <- "Noto Color Emoji"
output_dir <- "."

# Формула ниже будет применена явно к таблице рёбер каждого графа:
# penwidth = edge_width_min +
#   (events - min(events)) / (max(events) - min(events)) *
#   (edge_width_max - edge_width_min)

packages <- c("jsonlite", "dplyr", "tidyr", "purrr")
missing_packages <- packages[
  !vapply(packages, requireNamespace, logical(1), quietly = TRUE)
]

if (length(missing_packages) > 0) {
  install.packages(missing_packages, repos = "https://cloud.r-project.org")
}

invisible(lapply(packages, library, character.only = TRUE))

# ============================================================
# 2. ЗАГРУЗКА JSONL НАПРЯМУЮ ИЗ GITHUB
# ============================================================

json_lines <- readLines(data_url, warn = FALSE, encoding = "UTF-8")

sessions_list <- purrr::map(
  json_lines,
  jsonlite::fromJSON,
  simplifyVector = FALSE
)

length(json_lines)
length(sessions_list)


# Поля верхнего уровня первой сессии
names(sessions_list[[1]])
str(sessions_list[[1]], max.level = 1)

# Где лежат реплики и как устроена одна реплика
length(sessions_list[[1]]$turns)
names(sessions_list[[1]]$turns[[1]])
str(sessions_list[[1]]$turns[[1]], max.level = 2)

# Говорящий и его роль
sessions_list[[1]]$turns[[1]][c("turn_id", "speaker_id", "role")]

# Какие роли действительно встречаются во всём файле
roles_in_file <- sessions_list |>
  purrr::map("turns") |>
  purrr::flatten() |>
  purrr::map_chr("role") |>
  unique() |>
  sort()

roles_in_file

# ============================================================
# 3. РАЗВОРАЧИВАНИЕ В ДЛИННУЮ ТАБЛИЦУ
# ============================================================

sessions_tbl <- tibble::tibble(
  session_order = seq_along(sessions_list),
  session = sessions_list
) |>
  dplyr::mutate(
    session_id = purrr::map_chr(session, "session_id"),
    turns = purrr::map(session, "turns")
  ) |>
  dplyr::select(session_order, session_id, turns, session)

sessions_tbl |>
  dplyr::select(-session) |>
  head(3)

turns_long <- sessions_tbl |>
  dplyr::select(session_order, session_id, turns) |>
  tidyr::unnest_longer(
    turns,
    values_to = "turn"
  ) |>
  dplyr::group_by(session_order, session_id) |>
  dplyr::mutate(turn_index = dplyr::row_number()) |>
  dplyr::ungroup() |>
  tidyr::unnest_wider(turn) |>
  dplyr::transmute(
    session_order,
    session_id,
    turn_no = as.integer(turn_id),
    turn_index = as.integer(turn_index),
    speaker_id,
    role,
    text,
    word_count = as.integer(word_count),
    action
  ) |>
  dplyr::arrange(session_order, turn_no)

turns_long |> head(10)
dplyr::glimpse(turns_long)
nrow(turns_long)

turns_long |>
  dplyr::summarise(
    n_turns = dplyr::n(),
    index_mismatches = sum(turn_no != turn_index),
    missing_speaker = sum(is.na(speaker_id) | speaker_id == ""),
    missing_role = sum(is.na(role) | role == "")
  )

turns_long |> dplyr::count(role, sort = TRUE)


# ============================================================
# 4. АУДИТ КОРПУСА
# ============================================================

corpus_audit <- tibble::tibble(
  n_sessions = dplyr::n_distinct(turns_long$session_id),
  n_turns = nrow(turns_long),
  n_student_ids = turns_long |>
    dplyr::filter(role == "student") |>
    dplyr::summarise(n = dplyr::n_distinct(speaker_id)) |>
    dplyr::pull(n)
)

corpus_audit

session_audit <- turns_long |>
  dplyr::group_by(session_order, session_id) |>
  dplyr::summarise(
    n_turns = dplyr::n(),
    n_student_turns = sum(role == "student"),
    n_teacher_turns = sum(role == "teacher"),
    n_unknown_turns = sum(role == "unknown"),
    n_student_ids = dplyr::n_distinct(speaker_id[role == "student"]),
    teacher_share = n_teacher_turns / n_turns,
    .groups = "drop"
  )

sessions_without_students <- session_audit |>
  dplyr::filter(n_student_turns == 0)

session_audit |> head(10)
sessions_without_students
nrow(sessions_without_students)

teacher_share_by_session <- session_audit |>
  dplyr::select(session_id, n_turns, n_teacher_turns, teacher_share) |>
  dplyr::arrange(dplyr::desc(teacher_share))

teacher_share_by_session |> head(10)
summary(teacher_share_by_session$teacher_share)

speaker_role_conflicts <- turns_long |>
  dplyr::distinct(speaker_id, role) |>
  dplyr::count(speaker_id, name = "n_roles") |>
  dplyr::filter(n_roles > 1)

speaker_role_conflicts
nrow(speaker_role_conflicts)

# ============================================================
# 5. ЯДРО И ЭПИЗОДИЧЕСКОЕ УЧАСТИЕ
# ============================================================

session_sizes <- turns_long |>
  dplyr::count(session_id, name = "session_turns")

student_session <- turns_long |>
  dplyr::filter(role == "student") |>
  dplyr::count(session_id, speaker_id, name = "student_turns") |>
  dplyr::left_join(session_sizes, by = "session_id") |>
  dplyr::mutate(
    turn_share = student_turns / session_turns,
    participation_type = dplyr::if_else(
      turn_share >= core_share,
      "core",
      "episodic"
    )
  ) |>
  dplyr::arrange(session_id, dplyr::desc(student_turns))

student_session |> head(20)
student_session |> dplyr::count(participation_type)
summary(student_session$turn_share)

# ============================================================
# 6. ГРАФ 1: ДВУДОЛЬНЫЙ «УЧЕНИК — СЕССИЯ»
# ============================================================

bipartite_edges <- student_session |>
  dplyr::filter(student_turns >= edge_min_events)

if (nrow(bipartite_edges) > 0) {
  bipartite_edges <- bipartite_edges |>
    dplyr::mutate(
      penwidth = if (max(student_turns) == min(student_turns)) {
        (edge_width_min + edge_width_max) / 2
      } else {
        edge_width_min +
          (student_turns - min(student_turns)) /
          (max(student_turns) - min(student_turns)) *
          (edge_width_max - edge_width_min)
      },
      edge_style = dplyr::if_else(
        participation_type == "core",
        "solid",
        "dashed"
      )
    )
}

bipartite_edges |> head(10)
bipartite_edges |> dplyr::count(edge_style)
nrow(bipartite_edges)

Теперь вручную собираем строки DOT. В идентификатор DOT добавляется префикс типа узла, но на рисунке показывается только значок. Настоящий идентификатор помещается в tooltip и появляется при наведении в SVG.

bipartite_student_nodes <- turns_long |>
  dplyr::filter(role == "student") |>
  dplyr::distinct(speaker_id) |>
  dplyr::mutate(
    dot_id = paste0("student__", speaker_id),
    dot_line = sprintf(
      '  "%s" [shape=circle, label="👤", tooltip="%s"];',
      dot_id, speaker_id
    )
  )

bipartite_session_nodes <- sessions_tbl |>
  dplyr::distinct(session_id) |>
  dplyr::mutate(
    dot_id = paste0("session__", session_id),
    dot_line = sprintf(
      '  "%s" [shape=square, label="📄", tooltip="%s"];',
      dot_id, session_id
    )
  )

bipartite_edge_lines <- bipartite_edges |>
  dplyr::mutate(
    from_id = paste0("student__", speaker_id),
    to_id = paste0("session__", session_id),
    dot_line = sprintf(
      paste0(
        '  "%s" -- "%s" ',
        '[weight="%d", penwidth="%.2f", style="%s", ',
        'tooltip="%s: %d реплик"];'
      ),
      from_id, to_id, student_turns, penwidth, edge_style,
      participation_type, student_turns
    )
  )

bipartite_dot <- paste(
  c(
    "graph student_session {",
    "  layout=fdp;",
    "  overlap=false;",
    "  splines=true;",
    "  outputorder=edgesfirst;",
    sprintf(
      paste0(
        '  node [style=filled, fillcolor=white, color=black, ',
        'fontcolor=black, fixedsize=true, width=0.46, height=0.46, ',
        'fontname="%s"];'
      ),
      emoji_font
    ),
    "  edge [color=black];",
    bipartite_student_nodes$dot_line,
    bipartite_session_nodes$dot_line,
    bipartite_edge_lines$dot_line,
    "}"
  ),
  collapse = "\n"
)

cat(bipartite_dot)
writeLines(
  enc2utf8(bipartite_dot),
  file.path(output_dir, "student_session_bipartite.dot"),
  useBytes = TRUE
)

# ============================================================
# 7. ГРАФ 2: «СЛЕДУЮЩИЙ ГОВОРЯЩИЙ» С УЧИТЕЛЕМ
# ============================================================

if (session_number < 1 || session_number > nrow(sessions_tbl)) {
  stop("session_number выходит за границы таблицы сессий")
}

selected_session_id <- sessions_tbl$session_id[[session_number]]
selected_session_id

selected_turns <- turns_long |>
  dplyr::filter(session_id == selected_session_id) |>
  dplyr::arrange(turn_no)

selected_turns |>
  dplyr::select(turn_no, speaker_id, role, text) |>
  head(15)

transition_nodes_with_teacher <- selected_turns |>
  dplyr::distinct(speaker_id, role) |>
  dplyr::arrange(role, speaker_id)

transitions_with_teacher <- selected_turns |>
  dplyr::transmute(
    from = speaker_id,
    to = dplyr::lead(speaker_id)
  ) |>
  dplyr::filter(!is.na(to)) |>
  dplyr::count(from, to, name = "n_events") |>
  dplyr::arrange(dplyr::desc(n_events), from, to)

transitions_with_teacher |> head(20)
sum(transitions_with_teacher$n_events)
nrow(selected_turns) - 1L


all_speakers_with_teacher <- transition_nodes_with_teacher$speaker_id

transition_matrix_data <- transitions_with_teacher |>
  dplyr::mutate(
    from = factor(from, levels = all_speakers_with_teacher),
    to = factor(to, levels = all_speakers_with_teacher)
  )

transition_matrix_with_teacher <- xtabs(
  n_events ~ from + to,
  data = transition_matrix_data,
  drop.unused.levels = FALSE
)

transition_matrix_with_teacher

mutual_pairs_with_teacher <- transitions_with_teacher |>
  dplyr::filter(from != to, n_events >= edge_min_events) |>
  dplyr::inner_join(
    transitions_with_teacher |>
      dplyr::transmute(
        from_reverse = to,
        to_reverse = from,
        n_events_reverse = n_events
      ),
    by = c(
      "from" = "from_reverse",
      "to" = "to_reverse"
    )
  ) |>
  dplyr::filter(
    n_events_reverse >= edge_min_events,
    from < to
  ) |>
  dplyr::rename(n_events_forward = n_events) |>
  dplyr::group_by(from, to) |>
  dplyr::summarise(
    n_events_forward = max(n_events_forward),
    n_events_reverse = max(n_events_reverse),
    .groups = "drop"
  ) |>
  dplyr::arrange(dplyr::desc(n_events_forward + n_events_reverse))

mutual_pairs_with_teacher
nrow(mutual_pairs_with_teacher)


transition_edges_with_teacher <- transitions_with_teacher |>
  dplyr::filter(n_events >= edge_min_events)

if (nrow(transition_edges_with_teacher) > 0) {
  transition_edges_with_teacher <- transition_edges_with_teacher |>
    dplyr::mutate(
      penwidth = if (max(n_events) == min(n_events)) {
        (edge_width_min + edge_width_max) / 2
      } else {
        edge_width_min +
          (n_events - min(n_events)) /
          (max(n_events) - min(n_events)) *
          (edge_width_max - edge_width_min)
      }
    )
}

visible_ids_with_teacher <- union(
  transition_edges_with_teacher$from,
  transition_edges_with_teacher$to
)

isolated_students_with_teacher <- transition_nodes_with_teacher |>
  dplyr::filter(
    role == "student",
    !speaker_id %in% visible_ids_with_teacher
  )

transition_edges_with_teacher |> head(20)
isolated_students_with_teacher
nrow(isolated_students_with_teacher)

transition_node_lines_with_teacher <- transition_nodes_with_teacher |>
  dplyr::mutate(
    dot_id = paste0("person__", speaker_id),
    node_shape = dplyr::case_when(
      role == "teacher" ~ "doublecircle",
      TRUE ~ "circle"
    ),
    dot_line = sprintf(
      paste0(
        '  "%s" [shape=%s, label="👤", ',
        'tooltip="%s (%s)"];'
      ),
      dot_id, node_shape, speaker_id, role
    )
  )

transition_edge_lines_with_teacher <- transition_edges_with_teacher |>
  dplyr::mutate(
    from_id = paste0("person__", from),
    to_id = paste0("person__", to),
    dot_line = sprintf(
      paste0(
        '  "%s" -> "%s" ',
        '[weight="%d", penwidth="%.2f", style="solid", ',
        'arrowsize="%.2f", tooltip="%d переходов"];'
      ),
      from_id, to_id, n_events, penwidth, arrow_size, n_events
    )
  )

transition_dot_with_teacher <- paste(
  c(
    "digraph next_speaker_with_teacher {",
    "  layout=neato;",
    "  overlap=false;",
    "  splines=true;",
    "  outputorder=edgesfirst;",
    sprintf(
      paste0(
        '  node [style=filled, fillcolor=white, color=black, ',
        'fontcolor=black, fixedsize=true, width=0.52, height=0.52, ',
        'fontname="%s"];'
      ),
      emoji_font
    ),
    sprintf(
      '  edge [color=black, arrowsize="%.2f"];',
      arrow_size
    ),
    transition_node_lines_with_teacher$dot_line,
    transition_edge_lines_with_teacher$dot_line,
    "}"
  ),
  collapse = "\n"
)

cat(transition_dot_with_teacher)
writeLines(
  enc2utf8(transition_dot_with_teacher),
  file.path(output_dir, "next_speaker_with_teacher.dot"),
  useBytes = TRUE
)

# ============================================================
# 8. ВАРИАНТ: УЧИТЕЛЬ УДАЛЁН ДО ПОСТРОЕНИЯ ПЕРЕХОДОВ
# ============================================================

selected_turns_without_teacher <- selected_turns |>
  dplyr::filter(role != "teacher") |>
  dplyr::arrange(turn_no)

transition_nodes_without_teacher <- selected_turns_without_teacher |>
  dplyr::distinct(speaker_id, role) |>
  dplyr::arrange(role, speaker_id)

transitions_without_teacher <- selected_turns_without_teacher |>
  dplyr::transmute(
    from = speaker_id,
    to = dplyr::lead(speaker_id)
  ) |>
  dplyr::filter(!is.na(to)) |>
  dplyr::count(from, to, name = "n_events") |>
  dplyr::arrange(dplyr::desc(n_events), from, to)

transitions_without_teacher |> head(20)
sum(transitions_without_teacher$n_events)
nrow(selected_turns_without_teacher) - 1L

all_speakers_without_teacher <- transition_nodes_without_teacher$speaker_id

transition_matrix_data_without_teacher <- transitions_without_teacher |>
  dplyr::mutate(
    from = factor(from, levels = all_speakers_without_teacher),
    to = factor(to, levels = all_speakers_without_teacher)
  )

transition_matrix_without_teacher <- xtabs(
  n_events ~ from + to,
  data = transition_matrix_data_without_teacher,
  drop.unused.levels = FALSE
)

transition_matrix_without_teacher

mutual_pairs_without_teacher <- transitions_without_teacher |>
  dplyr::filter(from != to, n_events >= edge_min_events) |>
  dplyr::inner_join(
    transitions_without_teacher |>
      dplyr::transmute(
        from_reverse = to,
        to_reverse = from,
        n_events_reverse = n_events
      ),
    by = c(
      "from" = "from_reverse",
      "to" = "to_reverse"
    )
  ) |>
  dplyr::filter(
    n_events_reverse >= edge_min_events,
    from < to
  ) |>
  dplyr::rename(n_events_forward = n_events) |>
  dplyr::group_by(from, to) |>
  dplyr::summarise(
    n_events_forward = max(n_events_forward),
    n_events_reverse = max(n_events_reverse),
    .groups = "drop"
  ) |>
  dplyr::arrange(dplyr::desc(n_events_forward + n_events_reverse))

mutual_pairs_without_teacher

transition_edges_without_teacher <- transitions_without_teacher |>
  dplyr::filter(n_events >= edge_min_events)

if (nrow(transition_edges_without_teacher) > 0) {
  transition_edges_without_teacher <- transition_edges_without_teacher |>
    dplyr::mutate(
      penwidth = if (max(n_events) == min(n_events)) {
        (edge_width_min + edge_width_max) / 2
      } else {
        edge_width_min +
          (n_events - min(n_events)) /
          (max(n_events) - min(n_events)) *
          (edge_width_max - edge_width_min)
      }
    )
}

visible_ids_without_teacher <- union(
  transition_edges_without_teacher$from,
  transition_edges_without_teacher$to
)

isolated_students_without_teacher <- transition_nodes_without_teacher |>
  dplyr::filter(
    role == "student",
    !speaker_id %in% visible_ids_without_teacher
  )

isolated_students_without_teacher

transition_node_lines_without_teacher <- transition_nodes_without_teacher |>
  dplyr::mutate(
    dot_id = paste0("person__", speaker_id),
    node_shape = "circle",
    dot_line = sprintf(
      paste0(
        '  "%s" [shape=%s, label="👤", ',
        'tooltip="%s (%s)"];'
      ),
      dot_id, node_shape, speaker_id, role
    )
  )

transition_edge_lines_without_teacher <- transition_edges_without_teacher |>
  dplyr::mutate(
    from_id = paste0("person__", from),
    to_id = paste0("person__", to),
    dot_line = sprintf(
      paste0(
        '  "%s" -> "%s" ',
        '[weight="%d", penwidth="%.2f", style="solid", ',
        'arrowsize="%.2f", tooltip="%d переходов"];'
      ),
      from_id, to_id, n_events, penwidth, arrow_size, n_events
    )
  )

transition_dot_without_teacher <- paste(
  c(
    "digraph next_speaker_without_teacher {",
    "  layout=neato;",
    "  overlap=false;",
    "  splines=true;",
    "  outputorder=edgesfirst;",
    sprintf(
      paste0(
        '  node [style=filled, fillcolor=white, color=black, ',
        'fontcolor=black, fixedsize=true, width=0.52, height=0.52, ',
        'fontname="%s"];'
      ),
      emoji_font
    ),
    sprintf(
      '  edge [color=black, arrowsize="%.2f"];',
      arrow_size
    ),
    transition_node_lines_without_teacher$dot_line,
    transition_edge_lines_without_teacher$dot_line,
    "}"
  ),
  collapse = "\n"
)

cat(transition_dot_without_teacher)
writeLines(
  enc2utf8(transition_dot_without_teacher),
  file.path(output_dir, "next_speaker_without_teacher.dot"),
  useBytes = TRUE
)

transition_comparison <- tibble::tibble(
  variant = c("with_teacher", "without_teacher"),
  n_turns = c(
    nrow(selected_turns),
    nrow(selected_turns_without_teacher)
  ),
  n_raw_transitions = c(
    sum(transitions_with_teacher$n_events),
    sum(transitions_without_teacher$n_events)
  ),
  n_visible_edges = c(
    nrow(transition_edges_with_teacher),
    nrow(transition_edges_without_teacher)
  ),
  n_mutual_pairs = c(
    nrow(mutual_pairs_with_teacher),
    nrow(mutual_pairs_without_teacher)
  ),
  n_isolated_students = c(
    nrow(isolated_students_with_teacher),
    nrow(isolated_students_without_teacher)
  )
)

transition_comparison

# ============================================================
# НЕОБЯЗАТЕЛЬНАЯ ЛОКАЛЬНАЯ ОТРИСОВКА GRAPHVIZ
# ============================================================

if (nzchar(Sys.which("fdp"))) {
  system2(
    Sys.which("fdp"),
    c(
      "-Tsvg",
      shQuote(file.path(output_dir, "student_session_bipartite.dot")),
      "-o",
      shQuote(file.path(output_dir, "student_session_bipartite.svg"))
    )
  )
} else {
  message("Команда fdp не найдена: DOT всё равно создан.")
}

if (nzchar(Sys.which("neato"))) {
  system2(
    Sys.which("neato"),
    c(
      "-Tsvg",
      shQuote(file.path(output_dir, "next_speaker_with_teacher.dot")),
      "-o",
      shQuote(file.path(output_dir, "next_speaker_with_teacher.svg"))
    )
  )

  system2(
    Sys.which("neato"),
    c(
      "-Tsvg",
      shQuote(file.path(output_dir, "next_speaker_without_teacher.dot")),
      "-o",
      shQuote(file.path(output_dir, "next_speaker_without_teacher.svg"))
    )
  )
} else {
  message("Команда neato не найдена: DOT всё равно создан.")
}

<syntaxhighlight lang="text">
<graphviz>
digraph example {
  A -> B;
}
</graphviz>

Если вместо 👤 или 📄 отображается пустой квадрат, это проблема шрифта Graphviz, а не данных. Измените только параметр emoji_font в начале скрипта на шрифт с поддержкой этих символов, доступный на вашей системе, и повторите блоки экспорта.

Как читать два графа

Двудольный граф отвечает на вопрос, какие ученики присутствуют в каких сессиях и насколько интенсивно. Он не показывает порядок реплик. Толстое ребро означает больше реплик ученика в сессии; сплошное ребро — прохождение относительного порога ядра; пунктир — эпизодическое участие.

Граф следующего говорящего отвечает на вопрос, какие идентификаторы часто оказываются соседями в последовательности реплик. Направление важно: A → B и B → A считаются разными переходами. Взаимная пара проходит порог в обоих направлениях, но даже она не доказывает прямой диалог без анализа текста, контекста задания, видео или разметки адресата.

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

Упражнения

  1. Измените core_share с 0.05 на 0.10, повторите блоки 5–6 и сравните число сплошных и пунктирных рёбер. Объясните, почему веса рёбер не изменились, а тип некоторых рёбер изменился.
  2. Установите другое значение session_number, повторите блоки 7–9 и найдите сессию с наибольшим числом видимых переходов при том же edge_min_events.
  3. Для одной сессии сравните матрицы и социограммы с учителем и без учителя. Найдите хотя бы одну ученическую пару, связь которой появилась или усилилась после удаления учителя, и проверьте исходные номера реплик.
  4. Обобщите расчёт взаимных пар на все сессии: внутри каждой сессии упорядочьте реплики, постройте соседние пары, агрегируйте направления и посчитайте число взаимных пар при том же пороге. Не смешивайте одинаковые speaker_id из разных сессий до этапа группировки по session_id.

Источники данных и инструментов