Preference mapping is a key technique in sensory and consumer research. It links the sensory perception on products to the liking of products and hence provides clues to the development of new, well tasting, products. Even though it is a key technique, it is also a long standing problem how to perform such an analysis. In R the SensoMineR package provides a prefmap procedure. This post attempts to create such an analysis with Stan.
Data
Data are the coctail data from the SensoMineR package. After conversion to a scale 0 to 10 with 10 most liked, the product means are:
means
1 5.03
2 3.02
3 5.42
4 3.55
5 5.67
6 5.74
7 3.84
8 3.75
9 4.17
10 4.26
11 3.20
12 3.88
13 5.98
14 3.95
15 6.47
16 4.90
Model
The model is build upon my post of last week:
Mapping products in a space . What is added is a consumer section. Each consumer's preference is modeled as a ideal point, where liking is maximum, with points further away liked less and less. In mathematical terms the distance dependent function is max
liking * e
-distance*scale. The ideal point is captured by three numbers; its liking and its coordinates. The scale function is, for now, common for all consumers. In addition there is a lot of code for administration of all parameters.
model1 <- "
data {
int<lower=0> ndescriptor;
int<lower=0> nproduct;
matrix[nproduct,ndescriptor] profile;
int<lower=0> nconsumer;
matrix[nproduct,nconsumer] liking;
}
parameters {
row_vector[nproduct] prodsp1;
row_vector[nproduct] prodsp2;
real<lower=0,upper=1> sigma1;
real<lower=0,upper=1> sigma2;
matrix [nconsumer,2] optim;
vector <lower=0> [nconsumer] maxima;
real <lower=0> scale;
real <lower=0> sliking;
}
transformed parameters {
vector [ndescriptor] descrsp1;
vector [ndescriptor] descrsp2;
matrix[nproduct,ndescriptor] expected1;
matrix[nproduct,ndescriptor] expected2;
matrix[nproduct,ndescriptor] residual1;
vector[nproduct] distances;
matrix[nproduct,nconsumer] likepred;
descrsp1 <- profile'*prodsp1';
expected1 <- (descrsp1*prodsp1)';
residual1 <- profile-expected1;
descrsp2 <- profile'*prodsp2';
expected2 <- (descrsp2*prodsp2)';
for (i in 1:nconsumer) {
for (r in 1:nproduct) {
distances[r] <- sqrt(square(prodsp1[r]-optim[i,1])+
square(prodsp2[r]-optim[i,2]));
likepred[r,i] <- maxima[i]*exp(-distances[r]*scale);
}
}
}
model {
for (r in 1:nproduct) {
prodsp1[r] ~ normal(0,1);
prodsp2[r] ~ normal(0,1);
for (c in 1:ndescriptor) {
profile[r,c] ~ normal(expected1[r,c],sigma1);
residual1[r,c] ~ normal(expected2[r,c],sigma2);
}
for (i in 1:nconsumer) {
liking[r,i] ~ normal(likepred[r,i],sliking);
optim[i,1] ~ normal(0,2);
optim[i,2] ~ normal(0,2);
}
scale ~ normal(1,.1);
maxima ~ normal(5,2);
sliking ~ normal(2,1);
}
}
generated quantities {
vector [ndescriptor] descrspace1;
vector [ndescriptor] descrspace2;
row_vector [nproduct] prodspace1;
row_vector [nproduct] prodspace2;
matrix [nconsumer,2] optima;
prodspace1 <-(
((prodsp1[12]>0)*prodsp1)-
((prodsp1[12]<0)*prodsp1)
);
prodspace2 <-(
((prodsp2[12]>0)*prodsp2)-
((prodsp2[12]<0)*prodsp2)
);
descrspace1 <-(
((prodsp1[12]>0)*descrsp1)-
((prodsp1[12]<0)*descrsp1)
);
descrspace2 <-(
((prodsp2[12]>0)*descrsp2)-
((prodsp2[12]<0)*descrsp2)
);
for (i in 1:nconsumer) {
optima[i,1] <- (
((prodsp1[12]>0)*optim[i,1])-
((prodsp1[12]<0)*optim[i,1])
);
optima[i,2] <- (
((prodsp2[12]>0)*optim[i,2])-
((prodsp2[12]<0)*optim[i,2])
);
}
}
"
Analysis results
Sensominer's result
For comparative reasons the plot resulting from SensoMineR's carto() function. I have followed the parameter settings from the SensoMineR package to get this plot. Color is liking, numbered dots are products. The blue zone is best liked, as can be seen from the products with highest means residing there.
New method
In the plot the blue dots are samples of ideal points, the bigger black numbers are locations of products and the smaller red numbers are consumer's ideal points.
This is different from the SensoMineR map , the consumers have pulled well liked products such as 13 and 15 to the center. In a way, I suspect that in this analysis the consumer's preference has overruled most information from the sensory space. Given that, I will be splitting consumers.
Three groups of consumers
Three groups of consumers were created via k-means clustering. From sensory and consumer insight point of view the clusters may describe three different ways to experience the particular products. Obviously a clustering upon demographics or marketing segments may be equally valid, but I don't have that information. The cluster sizes are 15, 52 and 33 respectively.
Cluster 1
This cluster is characterized by liking for products 8 to 11. Compared to the original space, this cluster does not like products 13 and 15 so much, does not dislike product 4 and 12 so much.
Cluster 2
These are the bulk of the consumers and the result of all consumers is more pronounced. However, product 1 has shifter quite a distance to liked.
Cluster 3
This plot is again fairly similar to the all consumer plot. What is noticeable here is that there is a void in the center. The center of the most liked region is not occupied.
Next Steps
There are still some things to improve in this approach. Better tuning of the various priors in the model. Modeling the range of consumer's liking rather than solely their maximum. It may be possible to have the scale parameter subject dependent. Perhaps a better way to extract the dimensions from sensory space, thereby avoiding the Jacobian warning and using estimated standard deviations of the sensory profiling data. Finally, improved graphics.
Code
# Reading and first map
# senso.cocktail
# hedo.cocktail
library(SensoMineR)
data(cocktail)
res.pca <- PCA(senso.cocktail,graph=FALSE)
# SensoMineR does a dev.new for each graph, hence captured like this.
dev.new <- function() png('carto.png')
res.carto <- carto(res.pca$ind$coord[,1:2],
graph.tree=FALSE,
graph.corr=FALSE,
hedo.cocktail)
dev.off()
# reset default graph settings
rm(dev.new)
dev.new()
# model
library(rstan)
nprod <- 16
ndescr <- 13
nconsumer <- 100
sprofile <- as.matrix(scale(senso.cocktail))
datain <- list(
nproduct=nprod,
ndescriptor=ndescr,
profile=sprofile,
nconsumer=nconsumer,
liking = as.matrix(10-hedo.cocktail[,1:nconsumer])
)
data.frame(means=rowMeans(10-hedo.cocktail) )
model1 <- "
data {
int<lower=0> ndescriptor;
int<lower=0> nproduct;
matrix[nproduct,ndescriptor] profile;
int<lower=0> nconsumer;
matrix[nproduct,nconsumer] liking;
}
parameters {
row_vector[nproduct] prodsp1;
row_vector[nproduct] prodsp2;
real<lower=0,upper=1> sigma1;
real<lower=0,upper=1> sigma2;
matrix [nconsumer,2] optim;
vector <lower=0> [nconsumer] maxima;
real <lower=0> scale;
real <lower=0> sliking;
}
transformed parameters {
vector [ndescriptor] descrsp1;
vector [ndescriptor] descrsp2;
matrix[nproduct,ndescriptor] expected1;
matrix[nproduct,ndescriptor] expected2;
matrix[nproduct,ndescriptor] residual1;
vector[nproduct] distances;
matrix[nproduct,nconsumer] likepred;
descrsp1 <- profile'*prodsp1';
expected1 <- (descrsp1*prodsp1)';
residual1 <- profile-expected1;
descrsp2 <- profile'*prodsp2';
expected2 <- (descrsp2*prodsp2)';
for (i in 1:nconsumer) {
for (r in 1:nproduct) {
distances[r] <- sqrt(square(prodsp1[r]-optim[i,1])+
square(prodsp2[r]-optim[i,2]));
likepred[r,i] <- maxima[i]*exp(-distances[r]*scale);
}
}
}
model {
for (r in 1:nproduct) {
prodsp1[r] ~ normal(0,1);
prodsp2[r] ~ normal(0,1);
for (c in 1:ndescriptor) {
profile[r,c] ~ normal(expected1[r,c],sigma1);
residual1[r,c] ~ normal(expected2[r,c],sigma2);
}
for (i in 1:nconsumer) {
liking[r,i] ~ normal(likepred[r,i],sliking);
optim[i,1] ~ normal(0,2);
optim[i,2] ~ normal(0,2);
}
scale ~ normal(1,.1);
maxima ~ normal(5,2);
sliking ~ normal(2,1);
}
}
generated quantities {
vector [ndescriptor] descrspace1;
vector [ndescriptor] descrspace2;
row_vector [nproduct] prodspace1;
row_vector [nproduct] prodspace2;
matrix [nconsumer,2] optima;
prodspace1 <-(
((prodsp1[12]>0)*prodsp1)-
((prodsp1[12]<0)*prodsp1)
);
prodspace2 <-(
((prodsp2[12]>0)*prodsp2)-
((prodsp2[12]<0)*prodsp2)
);
descrspace1 <-(
((prodsp1[12]>0)*descrsp1)-
((prodsp1[12]<0)*descrsp1)
);
descrspace2 <-(
((prodsp2[12]>0)*descrsp2)-
((prodsp2[12]<0)*descrsp2)
);
for (i in 1:nconsumer) {
optima[i,1] <- (
((prodsp1[12]>0)*optim[i,1])-
((prodsp1[12]<0)*optim[i,1])
);
optima[i,2] <- (
((prodsp2[12]>0)*optim[i,2])-
((prodsp2[12]<0)*optim[i,2])
);
}
}
"
pars <- c('prodspace1','prodspace2','optima','scale','maxima')
fit <- stan(model_code = model1,
data = datain,
pars=pars)
# plotting
fitsamps <- as.data.frame(fit)
combiplot <- function(fitsamps,datain,labs) {
prod <- reshape(fitsamps,
drop=names(fitsamps)[33:ncol(fitsamps)],
direction='long',
varying=list(names(fitsamps)[1:16],
names(fitsamps)[17:32]),
timevar='sample',
times=1:16,
v.names=c('PDim1','PDim2')
)
sa <- sapply(1:16,function(x)
c(sample=x,
Dim1=mean(prod$PDim1[prod$sample==x]),
Dim2=mean(prod$PDim2[prod$sample==x])))
sa <- as.data.frame(t(sa))
optimindex <- grep('optima',names(fitsamps))
noptim <- datain$nconsumer
loc <- reshape(fitsamps,
drop=names(fitsamps)[(1:ncol(fitsamps))[-optimindex]],
direction='long',
varying=list(names(fitsamps)[optimindex[1:noptim]],
names(fitsamps)[optimindex[(1:noptim)+noptim]]),
timevar='subject',
times=1:noptim,
v.names=c('Dim1','Dim2')
)
locx <- loc[sample(nrow(loc),60000),]
plot(locx$Dim1,locx$Dim2,
col='blue',
pch=46,
cex=2,
xlim=c(-1,1)*.7,
ylim=c(-1,1)*.7)
sa2 <- sapply(1:noptim,function(x)
c(sample=x,
Dim1=mean(loc$Dim1[loc$subject==x]),
Dim2=mean(loc$Dim2[loc$subject==x])))
sa2 <- as.data.frame(t(sa2))
text(x=sa2$Dim1,y=sa2$Dim2,labels=labs,cex=.8,col='red')
text(x=sa$Dim1,y=sa$Dim2,labels=sa$sample,cex=1.5)
invisible(fitsamps)
}
combiplot(fitsamps,datain,1:100)
# three clusters
tlik <- t(scale(hedo.cocktail))
km <- kmeans(tlik,centers=3)
table(km$cluster)
datain1 <- list(
nproduct=nprod,
ndescriptor=ndescr,
profile=sprofile,
nconsumer=sum(km$cluster==1),
liking = as.matrix(10-hedo.cocktail[,km$cluster==1])
)
fit1 <- stan(model_code = model1,
data = datain1,
fit=fit,
pars=pars)
fitsamps1 <- as.data.frame(fit1)
#
datain2 <- list(
nproduct=nprod,
ndescriptor=ndescr,
profile=sprofile,
nconsumer=sum(km$cluster==2),
liking = as.matrix(10-hedo.cocktail[,km$cluster==2])
)
fit2 <- stan(model_code = model1,
data = datain2,
fit=fit,
pars=pars)
fitsamps2 <- as.data.frame(fit2)
##
datain3 <- list(
nproduct=nprod,
ndescriptor=ndescr,
profile=sprofile,
nconsumer=sum(km$cluster==3),
liking = as.matrix(10-hedo.cocktail[,km$cluster==3])
)
fit3 <- stan(model_code = model1,
data = datain3,
fit=fit,
pars=pars)
fitsamps3 <- as.data.frame(fit3)
combiplot(fitsamps1,datain1,which(km$cluster==1))
combiplot(fitsamps2,datain2,which(km$cluster==2))
combiplot(fitsamps3,datain3,which(km$cluster==3))