# Mirror CRAN
options(repos = c(CRAN = "https://cloud.r-project.org"))
packages <- c(
"tidyverse","readr","stringr","syuzhet","ggplot2","tidyr",
"knitr","kableExtra","tidytext","stopwords","wordcloud2"
)
to_install <- packages[!packages %in% installed.packages()[,1]]
if(length(to_install)) install.packages(to_install, dependencies = TRUE)
invisible(lapply(packages, library, character.only = TRUE))
options(stringsAsFactors = FALSE)
# Helpers
rename_es <- function(df) {
mapa <- c(
positive = "positivo",
negative = "negativo",
anger = "ira",
anticipation = "anticipación",
disgust = "asco",
fear = "miedo",
joy = "alegría",
sadness = "tristeza",
surprise = "sorpresa",
trust = "confianza"
)
comunes <- intersect(names(mapa), names(df))
dplyr::rename(df, !!!setNames(comunes, mapa[comunes]))
}
detect_cols <- function(df) {
cols <- names(df)
pos_col <- if ("positivo" %in% cols) "positivo" else "positive"
neg_col <- if ("negativo" %in% cols) "negativo" else "negative"
emo_candidates <- c("anger","anticipation","disgust","fear","joy","sadness","surprise","trust",
"ira","anticipación","asco","miedo","alegría","tristeza","sorpresa","confianza")
emo_cols <- intersect(emo_candidates, cols)
list(pos = pos_col, neg = neg_col, emos = emo_cols)
}
1) Lectura de fuente (diálogo humano)
Este documento usa por defecto
focus_group_conversacion_dialogo.txt.
> El análisis considera solo las respuestas de
participantes (P1:…P10:).
txt_path <- params$txt_path
lines <- readLines(txt_path, encoding = "UTF-8")
length(lines)
## [1] 244
head(lines, 10)
## [1] "I. Introducción y Calentamiento"
## [2] "MOD: Chicos, gracias por venir. Rapidito: nombre, distrito, con quién viven y qué hacen."
## [3] "P1: Hola, soy de SJL, vivo con mis papás y estoy estudiando administración... estoy en cuarto ciclo."
## [4] "P2: Qué tal, de Comas, vivo con mis tíos, trabajo en call center, turno tarde."
## [5] "P3: Yo soy de VES, vivo con mi hermano; hago delivery por horas, cuando sale."
## [6] "P4: Buenas, Independencia; alquilo con mi pareja y soy maestro de obra, ahorita en obra por Los Olivos."
## [7] "P5: Ate, con mis abuelos, estudio contabilidad... y hago cachuelos de Excel a veces."
## [8] "P6: SMP, con mi mamá; manejo mototaxi en el barrio."
## [9] "P7: Cercado... vivo con un amigo."
## [10] "P8: Chorrillos, con mis viejos; vendo por Instagram, ropa y cositas."
3) Análisis de sentimientos (NRC, español)
nrc <- syuzhet::get_nrc_sentiment(char_v = respuestas$texto, language = "spanish")
datos_sent <- dplyr::bind_cols(respuestas, nrc) |>
dplyr::mutate(polaridad = positive - negative) |>
rename_es()
det <- detect_cols(datos_sent)
3.1) Resumen por participante
sent_por_participante <- datos_sent |>
group_by(participante) |>
summarise(across(all_of(c(det$pos, det$neg, det$emos)), sum), .groups = "drop") |>
mutate(polaridad = .data[[det$pos]] - .data[[det$neg]]) |>
arrange(participante)
knitr::kable(sent_por_participante) |>
kableExtra::kable_styling(full_width = FALSE)
|
participante
|
positivo
|
negativo
|
ira
|
anticipación
|
asco
|
miedo
|
alegría
|
tristeza
|
sorpresa
|
confianza
|
polaridad
|
|
P1
|
14
|
4
|
3
|
3
|
3
|
5
|
3
|
2
|
1
|
6
|
10
|
|
P10
|
9
|
5
|
1
|
2
|
0
|
1
|
1
|
3
|
0
|
7
|
4
|
|
P2
|
15
|
6
|
1
|
6
|
3
|
0
|
3
|
3
|
3
|
6
|
9
|
|
P3
|
22
|
3
|
0
|
8
|
5
|
0
|
2
|
0
|
6
|
10
|
19
|
|
P4
|
4
|
1
|
0
|
4
|
1
|
2
|
1
|
0
|
2
|
2
|
3
|
|
P5
|
11
|
2
|
2
|
2
|
1
|
1
|
4
|
2
|
0
|
9
|
9
|
|
P6
|
4
|
3
|
0
|
0
|
0
|
1
|
0
|
2
|
0
|
2
|
1
|
|
P7
|
5
|
1
|
1
|
2
|
0
|
0
|
3
|
1
|
0
|
3
|
4
|
|
P8
|
7
|
4
|
0
|
1
|
1
|
0
|
0
|
2
|
2
|
2
|
3
|
|
P9
|
13
|
6
|
1
|
1
|
3
|
4
|
4
|
5
|
3
|
7
|
7
|
Gráfico: Polaridad por participante
ggplot(sent_por_participante, aes(x = participante, y = polaridad)) +
geom_col() +
geom_hline(yintercept = 0, linetype = "dashed") +
labs(title = "Polaridad por participante (positivo - negativo)",
x = "Participante", y = "Polaridad neta")

3.2) Resumen global
resumen_global <- datos_sent |>
summarise(across(all_of(c(det$pos, det$neg, det$emos)), sum)) |>
mutate(polaridad_total = .data[[det$pos]] - .data[[det$neg]])
knitr::kable(resumen_global) |>
kableExtra::kable_styling(full_width = FALSE)
|
positivo
|
negativo
|
ira
|
anticipación
|
asco
|
miedo
|
alegría
|
tristeza
|
sorpresa
|
confianza
|
polaridad_total
|
|
104
|
35
|
9
|
29
|
17
|
14
|
21
|
20
|
17
|
54
|
69
|
Gráfico: Emociones totales
# Paquetes
library(dplyr); library(tidyr); library(ggplot2); library(scales); library(stringr)
## Warning: package 'scales' was built under R version 4.4.3
##
## Adjuntando el paquete: 'scales'
## The following object is masked from 'package:syuzhet':
##
## rescale
## The following object is masked from 'package:purrr':
##
## discard
## The following object is masked from 'package:readr':
##
## col_factor
emociones_df <- resumen_global |>
select(all_of(det$emos)) |>
pivot_longer(everything(), names_to = "emocion", values_to = "n") |>
mutate(
emocion = str_to_sentence(emocion) # "alegria" -> "Alegria"
) |>
arrange(n) |>
mutate(emocion = factor(emocion, levels = unique(emocion))) # ordenar por conteo
# Paleta (Okabe–Ito adaptada + positivos/negativos si existen)
pal <- c(
"Ira" = "#D55E00",
"Anticipación" = "#F0E442",
"Asco" = "#009E73",
"Miedo" = "#56B4E9",
"Alegría" = "#E69F00",
"Tristeza" = "#0072B2",
"Sorpresa" = "#CC79A7",
"Confianza" = "#7F7F7F",
"Positivo" = "#1B9E77",
"Negativo" = "#D81B60"
)
ggplot(emociones_df, aes(x = emocion, y = n, fill = emocion)) +
geom_col(width = 0.72) +
geom_text(aes(label = comma(n)), hjust = -0.15, size = 3.4) +
coord_flip(clip = "off") +
scale_y_continuous(expand = expansion(mult = c(0, .08))) +
scale_fill_manual(values = pal, guide = "none") +
labs(title = "Emociones totales (NRC, español)",
x = "Emoción", y = "Conteo") +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 14),
axis.title.y = element_text(margin = margin(r = 8)),
axis.title.x = element_text(margin = margin(t = 6)),
panel.grid.minor = element_blank()
)

4) Nube de palabras
# Paquetes necesarios
# install.packages(c("dplyr","stringr","tidytext","tibble","stopwords","wordcloud2"))
library(dplyr); library(stringr); library(tidytext); library(tibble)
library(stopwords)
# 1) Fuente de texto (respuestas$texto o lineas$texto)
if (exists("respuestas") && "texto" %in% names(respuestas)) {
fuente <- respuestas %>% select(texto)
} else if (exists("lineas") && "texto" %in% names(lineas)) {
fuente <- lineas %>% select(texto)
} else {
stop("No encuentro un data frame con columna 'texto' (respuestas o lineas).")
}
# 2) Tokenización y limpieza
tokens <- fuente %>%
transmute(texto = texto %>%
str_to_lower() %>%
str_remove_all("https?://[^[:space:]]+") %>% # quitar URLs
str_remove_all("[0-9]+") %>%
str_replace_all("[[:punct:]]", " ")) %>%
tidytext::unnest_tokens(word, texto) %>%
filter(!is.na(word), nchar(word) > 2)
# 3) Stopwords ES
sw <- tibble(word = stopwords::stopwords("es"))
tokens_clean <- tokens %>%
anti_join(sw, by = "word") %>%
filter(!str_detect(word, "^[[:alpha:]]{1,2}$"))
# 4) Frecuencias
freq <- tokens_clean %>% count(word, sort = TRUE)
# 6) Top-N
topN <- 150
freq_top <- head(freq, topN)
# 7) Nube de palabras (wordcloud2::wordcloud2 es la función correcta)
wordcloud2::wordcloud2(data = freq_top,
size = 0.9,
minRotation = 0, maxRotation = 0)
Análisis por Pregunta del Guión
En esta sección se calcula el sentimiento por cada pregunta
del moderador (MOD) y se resumen métricas clave por bloque
(pregunta). El flujo es robusto a tildes y conserva los códigos de
participantes (P1–P10).
# Paquetes
# install.packages(c("dplyr","stringr","tidyr","tibble","purrr","ggplot2","tidytext","readr","knitr","syuzhet"))
library(dplyr); library(stringr); library(tidyr); library(tibble); library(purrr)
library(ggplot2); library(tidytext); library(readr); library(knitr); library(syuzhet)
# --- 1) Texto base y segmentación por MOD ---
if (exists("raw_tbl")) {
base_txt <- raw_tbl$raw
} else if (exists("texto")) {
base_txt <- if (is.character(texto)) texto else as.character(texto)
} else if (exists("params") && !is.null(params$txt_path)) {
base_txt <- readLines(params$txt_path, encoding = "UTF-8")
} else {
stop("No se encontró el objeto de texto base (raw_tbl$raw o texto) ni params$txt_path.")
}
full_txt <- paste(base_txt, collapse = "\n")
# Bloques que inician en 'MOD:'
bloques <- stringr::str_split(full_txt, "(?=\\bMOD:\\s*)")[[1]]
bloques <- bloques[stringr::str_detect(bloques, "^MOD:")]
preg_df <- tibble(
bloque_id = seq_along(bloques),
bloque = bloques
) %>%
mutate(
pregunta = stringr::str_match(bloque, "(?m)^MOD:\\s*(.*)$")[,2] %>% stringr::str_squish(),
respuestas = stringr::str_replace(bloque, "(?ms)^MOD:.*?(\\n|$)", "")
)
# --- 2) Intervenciones por participante ---
lineas <- preg_df %>%
mutate(linea = stringr::str_split(respuestas, "\\n")) %>%
tidyr::unnest(cols = c(linea)) %>%
mutate(linea = stringr::str_trim(linea)) %>%
filter(linea != "") %>%
filter(stringr::str_detect(linea, "^P(10|[1-9]):\\s")) %>%
mutate(
participante = stringr::str_match(linea, "^(P(10|[1-9])):")[,1],
texto = stringr::str_replace(linea, "^(P(10|[1-9])):\\s*", "")
) %>%
select(bloque_id, participante, texto)
# --- 3) Sentimiento NRC (ES) por intervención y agregación por pregunta ---
nrc_lineas <- lineas %>%
bind_cols(
purrr::map_dfr(lineas$texto, ~ syuzhet::get_nrc_sentiment(char_v = ., language = "spanish"))
)
resumen_bloque <- nrc_lineas %>%
group_by(bloque_id) %>%
summarise(
pregunta = first(preg_df$pregunta[preg_df$bloque_id == first(bloque_id)]),
pos = sum(positive, na.rm = TRUE),
neg = sum(negative, na.rm = TRUE),
score = pos - neg,
n_palabras= sum(stringr::str_count(texto, "\\S+"), na.rm = TRUE),
.groups = "drop"
) %>%
arrange(bloque_id)
# --- 4) Visual resumen por pregunta ---
ggplot(resumen_bloque, aes(x = reorder(paste0("#", bloque_id), score), y = score)) +
geom_col() +
coord_flip() +
labs(title = "Score de sentimiento por pregunta (NRC-ES, MOD)",
x = "Pregunta (ID de bloque)",
y = "Score (positive − negative)") +
theme_minimal()

# Tabla resumen
resumen_bloque %>%
mutate(pregunta = stringr::str_trunc(pregunta, 90)) %>%
knitr::kable(caption = "Resumen NRC-ES por pregunta (pregunta truncada a 90 caracteres)")
Resumen NRC-ES por pregunta (pregunta truncada a 90
caracteres)
| 1 |
Chicos, gracias por venir. Rapidito: nombre, distrito,
con quién viven y qué hacen. |
13 |
1 |
12 |
120 |
| 2 |
En la semana, ¿qué compran más seguido, así del día a
día? |
7 |
5 |
2 |
95 |
| 3 |
Si te falta algo “al paso”, ¿adónde caes cerca de casa
o chamba? |
5 |
0 |
5 |
73 |
| 4 |
Última compra pequeña: ¿qué fue, dónde y por qué? |
6 |
3 |
3 |
92 |
| 5 |
Bodegas del barrio: ¿cuál frecuentan y qué
compran? |
10 |
2 |
8 |
179 |
| 6 |
¿Y tiendas de conveniencia (Tambo, MASS, Precio Uno)
para qué las usan? |
3 |
2 |
1 |
69 |
| 7 |
Escenario 1: sales tarde, cansado, se te antoja gaseosa
helada y papitas. ¿Adónde y por… |
5 |
1 |
4 |
65 |
| 8 |
Escenario 2: estás cocinando y te falta aceite. ¿Adónde
y por qué? |
0 |
2 |
-2 |
65 |
| 9 |
Escenario 3: reunión improvisada, necesitas hielo,
piqueos, cervezas. ¿Primera opción? |
1 |
0 |
1 |
56 |
| 10 |
Atributos: precios. ¿Dónde sienten mejor precio y en
qué productos? |
4 |
1 |
3 |
76 |
| 11 |
Atributos: cercanía. ¿Prefieres esquina de tu casa o
una avenida por donde siempre pasas? |
5 |
0 |
5 |
55 |
| 12 |
Atributos: horarios. ¿Les ha pasado que bodega cerrada?
¿Valoran horario extendido? |
8 |
2 |
6 |
57 |
| 13 |
Servicio: trato en bodega vs Tambo/MASS. ¿Importa que
te fíen? |
6 |
1 |
5 |
97 |
| 14 |
Variedad/experiencia: variedad, limpieza, orden,
rapidez. |
8 |
0 |
8 |
74 |
| 15 |
¿Cambió algo con tantas tiendas de conveniencia
cerca? |
3 |
1 |
2 |
70 |
| 16 |
Formas de pago: ¿Yape/Plin en bodega también? |
8 |
3 |
5 |
48 |
| 17 |
¿Piden delivery (Rappi, etc.) a bodega o
conveniencia? |
0 |
6 |
-6 |
46 |
| 18 |
¿Algo que quisieran nuevo en la bodega (saludables,
listos para comer, licores)? |
7 |
3 |
4 |
64 |
| 19 |
Si fueran dueños de la bodega y compitieran con
Tambo/MASS, ¿tres cosas clave? |
5 |
0 |
5 |
54 |
| 20 |
¿Algo más para cerrar? |
0 |
2 |
-2 |
16 |
2) Top términos con polaridad por pregunta (usando NRC-ES)
syuzhet no expone el diccionario como tibble, pero
podemos clasificar cada palabra pasando el vector de
palabras al lexicón NRC-ES y luego contar por bloque.
# Tokenización de las intervenciones
tokens <- lineas %>%
transmute(bloque_id, texto = texto %>%
stringr::str_to_lower() %>%
stringr::str_remove_all("https?://[^[:space:]]+") %>%
stringr::str_remove_all("[0-9]+") %>%
stringr::str_replace_all("[[:punct:]]", " ")) %>%
tidytext::unnest_tokens(palabra, texto) %>%
filter(stringr::str_detect(palabra, "^[[:alpha:]]+$"),
nchar(palabra) > 2)
# Clasificar cada palabra con NRC-ES (positive/negative)
lex_words <- unique(tokens$palabra)
nrc_words <- syuzhet::get_nrc_sentiment(char_v = lex_words, language = "spanish")
nrc_words <- tibble(palabra = lex_words) %>% bind_cols(as_tibble(nrc_words)) %>%
transmute(palabra,
sentiment = case_when(positive > 0 & negative == 0 ~ "positive",
negative > 0 & positive == 0 ~ "negative",
TRUE ~ NA_character_)) %>%
filter(!is.na(sentiment))
tokens_labeled <- tokens %>% inner_join(nrc_words, by = "palabra")
top_terms <- tokens_labeled %>%
count(bloque_id, sentiment, palabra, sort = TRUE) %>%
group_by(bloque_id, sentiment) %>%
slice_max(n, n = 8, with_ties = FALSE) %>%
ungroup()
ggplot(top_terms, aes(x = reorder(palabra, n), y = n)) +
geom_col() +
coord_flip() +
facet_wrap(~ bloque_id + sentiment, scales = "free_y") +
labs(title = "Top términos con polaridad por pregunta (NRC-ES)",
x = NULL, y = "Frecuencia") +
theme_minimal(base_size = 5)
