Repository: doccano/doccano

Issue:
No way to restrict text classification labels to exactly one label to assign
Most classification tasks require exactly one label for each instance. This is also true for most text classification tasks, for example with sentiment classificaiton, and the possible labels negative, neutral, positive, each instance should receive one of the three labels, assigning e.g. both neutral and positive would make not sense.

Yet the text classification task in doccano still does not allow to restrict assignment to a single label, annotators are free to assign as many labels as they want, including all of them!

This limits the use of doccano for text classification tasks rather severely. The option to allow for any number of labels (0 to all of them) would still be good to have for multilabel classification tasks (e.g. assigning topics), but that is a much rarer annotation task in general. 


Discussion:
Actually, there is an option to do single-class classification in the backend. So if we implement the frontend, we can do it easily.

https://github.com/doccano/doccano/blob/b91944eb250a7340572580f6389168cd661a7326/app/api/models.py#L36

There was an earlier issue about this: 
https://github.com/doccano/doccano/issues/705#issuecomment-616270208
(remove the "multiple" from the combobox in MultiClassClassification.vue)

But when I try this now and do a local install of doccano afterwards as shown in https://github.com/doccano/doccano/issues/1192 then when starting the annotation task, there is no way at all to add a label. 
I do not understand this at all (all things javascript, html etc are way beyond my knowledge really)
but it surprises me as I thought the presence of this parameter just indicates if multiple values should be allowed in the combo-box.
One of the simplest ways is to implement the `BinaryClassification.vue` component. We can switch components between binary and multiple.

I'm now working on the auto labeling feature. It will be done in a week. After that, I can work on this problem.
Very rough implementation:

![](https://i.gyazo.com/40be9443521ffa866913486d190e0996.gif)
Super - This is exactly what I would need! How can I achieve this? Could you please provide a patch or instruction which lines to change?
I would be happy to run this from a patched checked out repo. 
Change [MultiClassClassification.vue](https://github.com/doccano/doccano/blob/master/frontend/components/organisms/annotation/MultiClassClassification.vue) as follows:

1. remove `multiple` from `v-combobox`
2. update `get` and `set` methods as follows

```javascript
annotatedLabels: {
  get() {
    const labelIds = this.annotations.map(item => item.label)
    return this.labels.find(item => labelIds.includes(item.id))
  },
  set(newValue) {
    if (this.annotations.length === 1) {
      this.remove(this.annotations[0].label)
    }
    this.add(newValue)
  }
}
```

This is a quick fix. I will implement it more propery.
Thanks that works great for me! 
For a proper implementation of this, there is another thing to be considered: if the classification tasks requires that exactly one label gets assigned (from k possible), then doccano could know which examples already have been annotated and which still have a missing label and show that number and ideally also allow to scroll to just those examples where a label is still missing. 

Currently a use would need to make sure to annotate examples in sequence to better keep track of what has been annotated already ... I am not sure how this works when "shuffle" / "randomized document order" is enabled for the task. 
Wasn't it implemented in #489, is it?

Resolving patch:
diff --git a/app/api/views/annotation.py b/app/api/views/annotation.py
--- a/app/api/views/annotation.py
+++ b/app/api/views/annotation.py
@@ -1,6 +1,5 @@
 from django.shortcuts import get_object_or_404
 from rest_framework import generics, status
-from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.views import APIView
@@ -16,21 +15,24 @@ class AnnotationList(generics.ListCreateAPIView):
     permission_classes = [IsAuthenticated & IsInProjectOrAdmin]
     swagger_schema = None
 
+    @property
+    def project(self):
+        return get_object_or_404(Project, pk=self.kwargs['project_id'])
+
     def get_serializer_class(self):
-        project = get_object_or_404(Project, pk=self.kwargs['project_id'])
-        self.serializer_class = project.get_annotation_serializer()
+        self.serializer_class = self.project.get_annotation_serializer()
         return self.serializer_class
 
     def get_queryset(self):
-        project = get_object_or_404(Project, pk=self.kwargs['project_id'])
-        model = project.get_annotation_class()
+        model = self.project.get_annotation_class()
         queryset = model.objects.filter(document=self.kwargs['doc_id'])
-        if not project.collaborative_annotation:
+        if not self.project.collaborative_annotation:
             queryset = queryset.filter(user=self.request.user)
         return queryset
 
     def create(self, request, *args, **kwargs):
-        self.check_single_class_classification(self.kwargs['project_id'], self.kwargs['doc_id'], request.user)
+        if self.project.single_class_classification:
+            self.get_queryset().delete()
         request.data['document'] = self.kwargs['doc_id']
         return super().create(request, args, kwargs)
 
@@ -42,20 +44,6 @@ def delete(self, request, *args, **kwargs):
         queryset.all().delete()
         return Response(status=status.HTTP_204_NO_CONTENT)
 
-    @staticmethod
-    def check_single_class_classification(project_id, doc_id, user):
-        project = get_object_or_404(Project, pk=project_id)
-        if not project.single_class_classification:
-            return
-
-        model = project.get_annotation_class()
-        annotations = model.objects.filter(document_id=doc_id)
-        if not project.collaborative_annotation:
-            annotations = annotations.filter(user=user)
-
-        if annotations.exists():
-            raise ValidationError('requested to create duplicate annotation for single-class-classification project')
-
 
 class AnnotationDetail(generics.RetrieveUpdateDestroyAPIView):
     lookup_url_kwarg = 'annotation_id'


Test patch:
diff --git a/app/api/tests/test_api.py b/app/api/tests/test_api.py
--- a/app/api/tests/test_api.py
+++ b/app/api/tests/test_api.py
@@ -921,7 +921,7 @@ def test_disallows_non_project_member_to_create_annotation(self):
         response = self.client.post(self.url, format='json', data=self.post_data)
         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
-    def test_disallows_second_annotation_for_single_class_project(self):
+    def test_allow_replace_annotation_for_single_class_project(self):
         self._patch_project(self.classification_project, 'single_class_classification', True)
 
         self.client.login(username=self.project_member_name, password=self.project_member_pass)
@@ -931,9 +931,9 @@ def test_disallows_second_annotation_for_single_class_project(self):
 
         response = self.client.post(self.classification_project_url, format='json',
                                     data={'label': self.classification_project_label_2.id})
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-    def test_disallows_second_annotation_for_single_class_shared_project(self):
+    def test_allow_replace_annotation_for_single_class_shared_project(self):
         self._patch_project(self.classification_project, 'single_class_classification', True)
         self._patch_project(self.classification_project, 'collaborative_annotation', True)
 
@@ -945,7 +945,7 @@ def test_disallows_second_annotation_for_single_class_shared_project(self):
         self.client.login(username=self.another_project_member_name, password=self.another_project_member_pass)
         response = self.client.post(self.classification_project_url, format='json',
                                     data={'label': self.classification_project_label_2.id})
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
     def _patch_project(self, project, attribute, value):
         old_value = getattr(project, attribute, None)