codebook / potato /server_utils /schemas /soft_label.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
9.29 kB
"""
Soft Label / Probability Distribution Layout
Generates a set of constrained sliders where annotators distribute probability
mass across labels. All sliders are constrained to sum to a fixed total (default 100).
Research basis:
- Fornaciari et al. (2021) "Beyond Black & White: Leveraging Annotator Disagreement
via Soft-Label Multi-Task Learning" ACL
- Plank et al. (2014) "Linguistically Debatable or Just Plain Wrong?" ACL
"""
import logging
from potato.ai.ai_help_wrapper import get_ai_wrapper
from .identifier_utils import (
safe_generate_layout,
generate_element_identifier,
generate_validation_attribute,
escape_html_content,
generate_layout_attributes,
generate_tooltip_html,
)
logger = logging.getLogger(__name__)
# Defaults
DEFAULT_TOTAL = 100
DEFAULT_MIN_PER_LABEL = 0
def generate_soft_label_layout(annotation_scheme):
"""
Generate HTML for a soft label / probability distribution interface.
Args:
annotation_scheme (dict): Configuration including:
- name: Schema identifier
- description: Display description
- labels: List of label names (strings or dicts with 'name')
- total: Sum constraint (default 100)
- min_per_label: Minimum per label (default 0)
- show_distribution_chart: Show bar chart (default true)
Returns:
tuple: (html_string, key_bindings)
"""
return safe_generate_layout(annotation_scheme, _generate_soft_label_layout_internal)
def _generate_soft_label_layout_internal(annotation_scheme):
schema_name = annotation_scheme["name"]
safe_schema = escape_html_content(schema_name)
description = annotation_scheme["description"]
labels = annotation_scheme.get("labels", [])
total = annotation_scheme.get("total", DEFAULT_TOTAL)
min_per_label = annotation_scheme.get("min_per_label", DEFAULT_MIN_PER_LABEL)
show_chart = annotation_scheme.get("show_distribution_chart", True)
if not labels:
raise ValueError(f"soft_label schema '{schema_name}' requires 'labels'")
layout_attrs = generate_layout_attributes(annotation_scheme)
validation = generate_validation_attribute(annotation_scheme)
# Normalize labels
label_names = []
for lbl in labels:
if isinstance(lbl, str):
label_names.append(lbl)
elif isinstance(lbl, dict) and "name" in lbl:
label_names.append(lbl["name"])
else:
raise ValueError(f"Invalid label format: {lbl}")
# Calculate initial equal distribution
initial_value = total // len(label_names)
remainder = total - (initial_value * len(label_names))
html = f"""
<form id="{safe_schema}" class="annotation-form soft_label shadcn-soft-label-container"
data-annotation-id="{escape_html_content(str(annotation_scheme.get('annotation_id', '')))}"
data-annotation-type="soft_label"
data-schema-name="{safe_schema}"
data-soft-label-total="{total}"
{layout_attrs}>
{get_ai_wrapper()}
<fieldset schema="{safe_schema}">
<legend class="shadcn-soft-label-title">{escape_html_content(description)}</legend>
<div class="soft-label-status">
<span class="soft-label-allocated" id="soft-label-allocated-{safe_schema}">
Allocated: <strong>{total}</strong> / {total}
</span>
<span class="soft-label-remaining" id="soft-label-remaining-{safe_schema}">
Remaining: <strong>0</strong>
</span>
</div>
"""
if show_chart:
html += f'<div class="soft-label-chart" id="soft-label-chart-{safe_schema}">'
for i, lbl in enumerate(label_names):
safe_lbl = escape_html_content(lbl)
html += f"""
<div class="soft-label-chart-bar-wrapper">
<div class="soft-label-chart-bar" id="soft-label-bar-{safe_schema}-{i}"
style="width: 0%;" data-label="{safe_lbl}"></div>
<span class="soft-label-chart-label">{safe_lbl}</span>
</div>
"""
html += "</div>"
html += '<div class="soft-label-sliders">'
for i, lbl in enumerate(label_names):
safe_lbl = escape_html_content(lbl)
identifiers = generate_element_identifier(schema_name, lbl, "range")
init_val = initial_value + (1 if i < remainder else 0)
tooltip_html = ""
if isinstance(labels[i], dict):
tooltip_html = generate_tooltip_html(labels[i])
html += f"""
<div class="soft-label-slider-row" {tooltip_html}>
<label class="soft-label-label" for="{identifiers['id']}">{safe_lbl}</label>
<input type="range"
min="{min_per_label}"
max="{total}"
step="1"
value="{init_val}"
class="soft-label-slider annotation-input"
id="{identifiers['id']}"
name="{identifiers['name']}"
schema="{identifiers['schema']}"
label_name="{identifiers['label_name']}"
validation="{validation}"
data-soft-label-group="{safe_schema}">
<span class="soft-label-value" id="soft-label-val-{identifiers['id']}">{init_val}</span>
</div>
"""
html += """
</div>
</fieldset>
</form>
"""
# Inline JS for sum constraint
html += f"""
<script>
(function() {{
const schema = "{safe_schema}";
const total = {total};
const minPerLabel = {min_per_label};
const sliders = document.querySelectorAll('[data-soft-label-group="' + schema + '"]');
function updateSoftLabel(changedSlider) {{
const values = [];
sliders.forEach(s => values.push(parseInt(s.value)));
const currentSum = values.reduce((a, b) => a + b, 0);
const diff = currentSum - total;
if (diff !== 0) {{
// Proportionally adjust other sliders
const otherSliders = Array.from(sliders).filter(s => s !== changedSlider);
const otherSum = otherSliders.reduce((acc, s) => acc + parseInt(s.value), 0);
if (otherSum > 0) {{
let remaining = diff;
for (const s of otherSliders) {{
const oldVal = parseInt(s.value);
const proportion = oldVal / otherSum;
let adjustment = Math.round(proportion * diff);
const newVal = Math.max(minPerLabel, oldVal - adjustment);
const actualAdj = oldVal - newVal;
s.value = newVal;
remaining -= actualAdj;
}}
// Fix rounding errors
if (remaining !== 0) {{
for (const s of otherSliders) {{
const oldVal = parseInt(s.value);
const newVal = Math.max(minPerLabel, oldVal - remaining);
remaining -= (oldVal - newVal);
s.value = newVal;
if (remaining === 0) break;
}}
}}
}} else if (diff > 0) {{
changedSlider.value = parseInt(changedSlider.value) - diff;
}}
}}
// Update displays
sliders.forEach(s => {{
const valEl = document.getElementById('soft-label-val-' + s.id);
if (valEl) valEl.textContent = s.value;
}});
// Update chart
sliders.forEach((s, idx) => {{
const bar = document.getElementById('soft-label-bar-' + schema + '-' + idx);
if (bar) {{
const pct = (parseInt(s.value) / total) * 100;
bar.style.width = pct + '%';
}}
}});
// Update allocated/remaining indicators
const newSum = Array.from(sliders).reduce((acc, s) => acc + parseInt(s.value), 0);
const allocEl = document.getElementById('soft-label-allocated-' + schema);
const remEl = document.getElementById('soft-label-remaining-' + schema);
if (allocEl) {{
allocEl.innerHTML = 'Allocated: <strong>' + newSum + '</strong> / ' + total;
}}
if (remEl) {{
const remaining = total - newSum;
remEl.innerHTML = 'Remaining: <strong>' + remaining + '</strong>';
}}
}}
sliders.forEach(s => {{
s.addEventListener('input', function() {{
updateSoftLabel(this);
}});
}});
// Initialize
if (sliders.length > 0) updateSoftLabel(sliders[0]);
}})();
</script>
"""
key_bindings = []
logger.info(f"Generated soft_label layout for {schema_name} with {len(label_names)} labels")
return html, key_bindings